From ee60bf546f8c193b2c570a93f39652e9df841917 Mon Sep 17 00:00:00 2001 From: Brian Lambert Date: Mon, 21 Oct 2024 15:49:32 -0700 Subject: [PATCH] Data Grid UI - Smooth scrolling (#5078) This PR adds smooth scrolling to the Data Table component. --- src/vs/base/common/positronUtilities.ts | 12 +- .../addEditRowFilterModalPopup.tsx | 4 +- .../columnSelectorDataGridInstance.tsx | 142 +++- .../components/columnSelectorModalPopup.css | 6 +- .../components/columnSelectorModalPopup.tsx | 3 +- .../components/dropDownColumnSelector.tsx | 103 ++- .../components/dataExplorer.tsx | 185 +++-- .../classes/dataGridInstance.ts | 746 +++++++++++------- .../components/dataGridColumnHeader.css | 6 +- .../components/dataGridColumnHeader.tsx | 4 +- .../components/dataGridColumnHeaders.tsx | 30 +- .../components/dataGridRow.tsx | 23 +- .../components/dataGridRowCell.tsx | 10 +- .../components/dataGridRowHeader.tsx | 6 +- .../components/dataGridRowHeaders.css | 2 +- .../components/dataGridRowHeaders.tsx | 24 +- .../components/dataGridScrollbar.css | 23 +- .../components/dataGridScrollbar.tsx | 211 +++-- .../components/dataGridScrollbarCorner.tsx | 4 +- .../components/dataGridWaffle.css | 2 + .../components/dataGridWaffle.tsx | 192 ++--- .../browser/components/columnSummaryCell.tsx | 4 +- .../browser/positronDataExplorerInstance.ts | 41 +- .../browser/positronDataExplorerService.ts | 12 +- .../browser/tableDataDataGridInstance.tsx | 260 +++--- .../browser/tableSummaryDataGridInstance.tsx | 302 ++++--- .../common/columnSchemaCache.ts | 12 +- .../common/layoutManager.ts | 477 +++++++++++ .../common/tableDataCache.ts | 238 +++--- .../test/common/layoutManager.test.ts | 687 ++++++++++++++++ 30 files changed, 2566 insertions(+), 1205 deletions(-) create mode 100644 src/vs/workbench/services/positronDataExplorer/common/layoutManager.ts create mode 100644 src/vs/workbench/test/common/layoutManager.test.ts diff --git a/src/vs/base/common/positronUtilities.ts b/src/vs/base/common/positronUtilities.ts index e9c94822043..3c220eaf4da 100644 --- a/src/vs/base/common/positronUtilities.ts +++ b/src/vs/base/common/positronUtilities.ts @@ -9,14 +9,14 @@ type Mapping = Record; type Argument = Value | Mapping; /** - * Ensures that a given value is within a range. + * Ensures that a given value is within a range of values. * @param value The value. - * @param min The minimum value, inclusive. - * @param max The maximum value, inclusive. - * @returns The value. + * @param minimumValue The minimum value, inclusive. + * @param maximumValue The maximum value, inclusive. + * @returns The pinned value. */ -export const pinToRange = (value: number, min: number, max: number) => - Math.min(Math.max(value, min), max); +export const pinToRange = (value: number, minimumValue: number, maximumValue: number) => + Math.min(Math.max(value, minimumValue), maximumValue); /** * optionalValue function. Returns the value, if it is not undefined; otherwise, returns the default value. diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/addEditRowFilterModalPopup.tsx b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/addEditRowFilterModalPopup.tsx index 905914cde51..bf1a7e77a2b 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/addEditRowFilterModalPopup.tsx +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/addEditRowFilterModalPopup.tsx @@ -21,10 +21,10 @@ import { DropDownListBoxSeparator } from 'vs/workbench/browser/positronComponent import { DataExplorerClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeDataExplorerClient'; import { DropDownListBox, DropDownListBoxEntry } from 'vs/workbench/browser/positronComponents/dropDownListBox/dropDownListBox'; import { ColumnSchema, ColumnDisplayType, RowFilterCondition } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; +import { dataExplorerExperimentalFeatureEnabled } from 'vs/workbench/services/positronDataExplorer/common/positronDataExplorerExperimentalConfig'; import { RowFilterParameter } from 'vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/rowFilterParameter'; import { DropDownColumnSelector } from 'vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/dropDownColumnSelector'; import { RangeRowFilterDescriptor, RowFilterDescriptor, RowFilterDescrType, RowFilterDescriptorComparison, RowFilterDescriptorIsBetween, RowFilterDescriptorIsEmpty, RowFilterDescriptorIsNotBetween, RowFilterDescriptorIsNotEmpty, SingleValueRowFilterDescriptor, RowFilterDescriptorIsNotNull, RowFilterDescriptorIsNull, RowFilterDescriptorSearch, RowFilterDescriptorIsTrue, RowFilterDescriptorIsFalse } from 'vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/rowFilterDescriptor'; -import { dataExplorerExperimentalFeatureEnabled } from 'vs/workbench/services/positronDataExplorer/common/positronDataExplorerExperimentalConfig'; /** * Validates a row filter value. @@ -594,7 +594,7 @@ export const AddEditRowFilterModalPopup = (props: AddEditRowFilterModalPopupProp return false; } - // The the second row filter value is valid. + // The second row filter value is valid. return true; }; diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorDataGridInstance.tsx b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorDataGridInstance.tsx index d1070f2295f..6bc8e66312d 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorDataGridInstance.tsx +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorDataGridInstance.tsx @@ -9,8 +9,8 @@ import * as React from 'react'; // Other dependencies. import { Emitter } from 'vs/base/common/event'; import { DataGridInstance } from 'vs/workbench/browser/positronDataGrid/classes/dataGridInstance'; -import { ColumnSchema } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; import { ColumnSchemaCache } from 'vs/workbench/services/positronDataExplorer/common/columnSchemaCache'; +import { BackendState, ColumnSchema } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; import { DataExplorerClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeDataExplorerClient'; import { ColumnSelectorCell } from 'vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorCell'; @@ -25,11 +25,6 @@ const ROW_HEIGHT = 26; export class ColumnSelectorDataGridInstance extends DataGridInstance { //#region Private Properties - /** - * Gets the data explorer client instance. - */ - private readonly _dataExplorerClientInstance: DataExplorerClientInstance; - /** * Gets or sets the search text. */ @@ -47,24 +42,56 @@ export class ColumnSelectorDataGridInstance extends DataGridInstance { //#endregion Private Properties + //#region Static Methods + + /** + * Creates a new column selector data grid instance. + * @param dataExplorerClientInstance The data explorer client instance. + * @returns A Promise that resolves when the operation is + * complete. + */ + public static async create( + dataExplorerClientInstance: DataExplorerClientInstance, + ): Promise { + try { + // Get the backend state so that we can get the initial number of columns. + const backedState = await dataExplorerClientInstance.getBackendState(); + + // Return a new instance of the column selector data grid instance. + return new ColumnSelectorDataGridInstance( + backedState.table_shape.num_columns, + dataExplorerClientInstance + ); + } catch { + return undefined; + } + } + + //#endregion Static Methods + //#region Constructor /** * Constructor. - * @param dataExplorerClientInstance The DataExplorerClientInstance. + * @param initialColumns The initial number of columns. + * @param _dataExplorerClientInstance The data explorer client instance. */ - constructor(dataExplorerClientInstance: DataExplorerClientInstance) { + protected constructor( + initialColumns: number, + private readonly _dataExplorerClientInstance: DataExplorerClientInstance, + ) { // Call the base class's constructor. super({ columnHeaders: false, rowHeaders: false, - defaultColumnWidth: 100, + defaultColumnWidth: 0, defaultRowHeight: ROW_HEIGHT, columnResize: false, rowResize: false, horizontalScrollbar: false, verticalScrollbar: true, - scrollbarWidth: 8, + scrollbarThickness: 8, + scrollbarOverscroll: 0, useEditorFont: false, automaticLayout: true, rowsMargin: 4, @@ -74,19 +101,54 @@ export class ColumnSelectorDataGridInstance extends DataGridInstance { selection: false }); - // Set the data explorer client instance. - this._dataExplorerClientInstance = dataExplorerClientInstance; + // Create the column schema cache. + this._register( + this._columnSchemaCache = new ColumnSchemaCache(this._dataExplorerClientInstance) + ); + + // Set the initial layout entries in the row layout manager. + this._rowLayoutManager.setLayoutEntries(initialColumns); + + /** + * Updates the data grid instance. + * @param state The state, if known; otherwise, undefined. + */ + const updateDataGridInstance = async (state?: BackendState) => { + // Get the backend state, if it was not supplied. + if (!state) { + state = await this._dataExplorerClientInstance.getBackendState(); + } + + // Set the layout entries in the row layout manager. + this._rowLayoutManager.setLayoutEntries(state.table_shape.num_columns); + + // Scroll to the top. + await this.setScrollOffsets(0, 0); + }; + + // Add the onDidSchemaUpdate event handler. + this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => + // Update the data grid instance. + updateDataGridInstance() + )); + + // Add the onDidDataUpdate event handler. + this._register(this._dataExplorerClientInstance.onDidDataUpdate(async () => + // Update the data grid instance. + updateDataGridInstance + )); + + // Add the onDidUpdateBackendState event handler. + this._register(this._dataExplorerClientInstance.onDidUpdateBackendState(async state => + // Update the data grid instance. + updateDataGridInstance(state) + )); - // Allocate and initialize the column schema cache. - this._columnSchemaCache = new ColumnSchemaCache(dataExplorerClientInstance); + // Add the onDidUpdateCache event handler. this._register(this._columnSchemaCache.onDidUpdateCache(() => + // Fire the onDidUpdate event. this._onDidUpdateEmitter.fire() )); - - // Add the onDidSchemaUpdate event handler. - this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => { - await this.setScreenPosition(0, 0); - })); } //#endregion Constructor @@ -107,6 +169,23 @@ export class ColumnSelectorDataGridInstance extends DataGridInstance { return this._columnSchemaCache.columns; } + /** + * Gets the scroll width. + */ + override get scrollWidth() { + return 0; + } + + /** + * Gets the first column. + */ + override get firstColumn() { + return { + columnIndex: 0, + left: 0 + }; + } + //#endregion DataGridInstance Properties //#region DataGridInstance Methods @@ -116,29 +195,24 @@ export class ColumnSelectorDataGridInstance extends DataGridInstance { * @returns A Promise that resolves when the operation is complete. */ override async fetchData() { - await this._columnSchemaCache.updateCache({ - searchText: this._searchText, - firstColumnIndex: this.firstRowIndex, - visibleColumns: this.screenRows - }); + const rowDescriptor = this.firstRow; + if (rowDescriptor) { + await this._columnSchemaCache.update({ + searchText: this._searchText, + firstColumnIndex: rowDescriptor.rowIndex, + visibleColumns: this.screenRows + }); + } } /** - * Gets the the width of a column. + * Gets the width of a column. * @param columnIndex The column index. */ override getColumnWidth(columnIndex: number): number { return this.layoutWidth - 8; } - /** - * Gets the the height of a row. - * @param rowIndex The row index. - */ - override getRowHeight(rowIndex: number): number { - return ROW_HEIGHT; - } - selectItem(rowIndex: number): void { // Get the column schema for the row index. const columnSchema = this._columnSchemaCache.getColumnSchema(rowIndex); @@ -201,7 +275,7 @@ export class ColumnSelectorDataGridInstance extends DataGridInstance { // select the first available row after fetching so that users cat hit "enter" // to make an immediate confirmation on what they were searching for - if (this.visibleRows) { + if (this.rows > 0) { this.showCursor(); this.setCursorRow(0); } diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.css b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.css index 047d29dad13..db5a7387174 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.css +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.css @@ -7,16 +7,16 @@ width: 100%; height: 100%; display: grid; - grid-template-rows: [search] 34px [view] 1fr [end]; + grid-template-rows: [column-selector-search] 34px [column-selector-data-grid] 1fr [end-column-selector-data-grid]; } .column-selector .column-selector-search { - grid-row: search / view; + grid-row: column-selector-search / column-selector-data-grid; border-bottom: 1px solid var(--vscode-positronDataExplorer-border); } .column-selector .column-selector-data-grid { - grid-row: view / end; + grid-row: column-selector-data-grid / end-column-selector-data-grid; } diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.tsx b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.tsx index 4d478d66e42..df12914247f 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.tsx +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/columnSelectorModalPopup.tsx @@ -30,7 +30,6 @@ interface ColumnSelectorModalPopupProps { readonly anchorElement: HTMLElement; readonly searchInput?: string; readonly focusInput?: boolean; - readonly onItemHighlighted: (columnSchema: ColumnSchema) => void; readonly onItemSelected: (columnSchema: ColumnSchema) => void; } @@ -49,7 +48,7 @@ export const ColumnSelectorModalPopup = (props: ColumnSelectorModalPopupProps) = // Drive focus into the data grid so the user can immediately navigate. props.columnSelectorDataGridInstance.setCursorPosition(0, 0); positronDataGridRef.current.focus(); - }, []); + }, [props.columnSelectorDataGridInstance, props.focusInput]); useEffect(() => { // Create the disposable store for cleanup. diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/dropDownColumnSelector.tsx b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/dropDownColumnSelector.tsx index cc68ea0c617..46ab9a8509a 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/dropDownColumnSelector.tsx +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/addEditRowFilterModalPopup/components/dropDownColumnSelector.tsx @@ -11,12 +11,15 @@ import * as React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; // eslint-disable-line no-duplicate-imports // Other dependencies. +import { localize } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { Button } from 'vs/base/browser/ui/positronComponents/button/button'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ColumnSchema } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; +import { OKModalDialog } from 'vs/workbench/browser/positronComponents/positronModalDialog/positronOKModalDialog'; +import { VerticalStack } from 'vs/workbench/browser/positronComponents/positronModalDialog/components/verticalStack'; import { PositronModalReactRenderer } from 'vs/workbench/browser/positronModalReactRenderer/positronModalReactRenderer'; import { DataExplorerClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeDataExplorerClient'; import { columnSchemaDataTypeIcon } from 'vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/utility/columnSchemaUtilities'; @@ -49,41 +52,77 @@ export const DropDownColumnSelector = (props: DropDownColumnSelectorProps) => { const [title, _setTitle] = useState(props.title); const [selectedColumnSchema, setSelectedColumnSchema] = useState(props.selectedColumnSchema); - const onPressed = useCallback((focusInput?: boolean) => { - // Create the renderer. - const renderer = new PositronModalReactRenderer({ - keybindingService: props.keybindingService, - layoutService: props.layoutService, - container: props.layoutService.getContainer(DOM.getWindow(ref.current)), - disableCaptures: true, // permits the usage of the enter key where applicable - onDisposed: () => { - ref.current.focus(); - } - }); - + const onPressed = useCallback(async (focusInput?: boolean) => { // Create the column selector data grid instance. - const columnSelectorDataGridInstance = new ColumnSelectorDataGridInstance( - props.dataExplorerClientInstance + const columnSelectorDataGridInstance = await ColumnSelectorDataGridInstance.create( + props.dataExplorerClientInstance, ); - // Show the drop down list box modal popup. - renderer.render( - { - console.log(`onItemHighlighted ${columnSchema.column_name}`); - }} - onItemSelected={columnSchema => { - renderer.dispose(); - setSelectedColumnSchema(columnSchema); - props.onSelectedColumnSchemaChanged(columnSchema); - }} - /> - ); + // Get the container. + const container = props.layoutService.getContainer(DOM.getWindow(ref.current)); + + // If the column selector data grid instance could not be created, alert the user. + // Otherwise, show the column selector modal popup. + if (!columnSelectorDataGridInstance) { + // Create the modal React renderer. + const renderer = new PositronModalReactRenderer({ + keybindingService: props.keybindingService, + layoutService: props.layoutService, + container + }); + + // Get the title and message. + const title = localize('positron.dataExplorer.selectColumn', "Select Column"); + const message = localize( + 'positron.dataExplorer.unableToOpenTheColumnSelector', + "Unable to open the column selector." + ); + + // Inform the user that the column selector data grid instance could not be created. + renderer.render( + { + renderer.dispose(); + }} + onCancel={() => renderer.dispose()}> + +
{message}
+
+
+ ); + } else { + // Create the renderer. + const renderer = new PositronModalReactRenderer({ + keybindingService: props.keybindingService, + layoutService: props.layoutService, + container, + disableCaptures: true, // permits the usage of the enter key where applicable + onDisposed: () => { + columnSelectorDataGridInstance.dispose(); + ref.current.focus(); + } + }); + + // Show the drop down list box modal popup. + renderer.render( + { + renderer.dispose(); + setSelectedColumnSchema(columnSchema); + props.onSelectedColumnSchemaChanged(columnSchema); + }} + /> + ); + } }, [props]); const onKeyDown = useCallback((evt: KeyboardEvent) => { diff --git a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/dataExplorer.tsx b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/dataExplorer.tsx index ee3ff628f5a..34c84d87c98 100644 --- a/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/dataExplorer.tsx +++ b/src/vs/workbench/browser/positronDataExplorer/components/dataExplorerPanel/components/dataExplorer.tsx @@ -61,99 +61,99 @@ export const DataExplorer = () => { // Calculate the horizontal cell padding. This is a setting, so it doesn't change over the // lifetime of the table data data grid instance. const horizontalCellPadding = - (context.instance.tableDataDataGridInstance.horizontalCellPadding * 2); - - // Set the column header width calculator. Column header widths are measured using the font - // information from the column name exemplar and the type name exemplar. These exemplars - // must be styled the same as the data grid title and description. - context.instance.tableDataDataGridInstance.setColumnHeaderWidthCalculator( - (columnName: string, typeName: string) => { - // Calculate the basic column header width. This allows for horizontal cell padding, - // the sorting button, and the border to be displayed, at a minimum. - const basicColumnHeaderWidth = - horizontalCellPadding + - SORTING_BUTTON_WIDTH + - 1; // +1 for the border. - - // If the column header is empty, return the basic column header width. - if (!columnName && !typeName) { - return basicColumnHeaderWidth; - } - - // Create a canvas and create a 2D rendering context for it to measure text. - const canvas = window.document.createElement('canvas'); - const canvasRenderingContext2D = canvas.getContext('2d'); - - // If the 2D canvas rendering context couldn't be created, return the basic column - // header width. - if (!canvasRenderingContext2D) { - return basicColumnHeaderWidth; - } - - // Set the column name width. - let columnNameWidth; - if (!columnName) { - columnNameWidth = 0; - } else { - // Measure the column name width using the font of the column name exemplar. - const columnNameExemplarStyle = - DOM.getComputedStyle(columnNameExemplar.current); - canvasRenderingContext2D.font = columnNameExemplarStyle.font; - columnNameWidth = canvasRenderingContext2D.measureText(columnName).width; - } - - // Set the type name width. - let typeNameWidth; - if (!typeName) { - typeNameWidth = 0; - } else { - // Measure the type name width using the font of the type name exemplar. - const typeNameExemplarStyle = DOM.getComputedStyle(typeNameExemplar.current); - canvasRenderingContext2D.font = typeNameExemplarStyle.font; - typeNameWidth = canvasRenderingContext2D.measureText(typeName).width; - } - - // Calculate return the column header width. - return Math.ceil(Math.max(columnNameWidth, typeNameWidth) + basicColumnHeaderWidth); - } - ); + context.instance.tableDataDataGridInstance.horizontalCellPadding * 2; // Calculate the width of a sort digit. The sort index is styled with font-variant-numeric // tabular-nums, so we can calculate the width of the sort index by multiplying the width of - // a sort digit by the length of the sort index. + // a sort digit by 2. const canvas = window.document.createElement('canvas'); const canvasRenderingContext2D = canvas.getContext('2d'); - let sortIndexDigitWidth; + let sortIndexWidth; if (!canvasRenderingContext2D) { - sortIndexDigitWidth = 0; + sortIndexWidth = 0; } else { const sortIndexExemplarStyle = DOM.getComputedStyle(sortIndexExemplar.current); canvasRenderingContext2D.font = sortIndexExemplarStyle.font; - sortIndexDigitWidth = canvasRenderingContext2D.measureText('1').width; + sortIndexWidth = canvasRenderingContext2D.measureText('99').width; } - // Set the sort index width calculator. Sort index widths are calculated. - context.instance.tableDataDataGridInstance.setSortIndexWidthCalculator(sortIndex => - Math.ceil(sortIndex.toString().length * sortIndexDigitWidth) - ); + /** + * The column header width calculator. + * @param columnName The column name. + * @param typeName The type name. + * @returns The column header width. + */ + const columnHeaderWidthCalculator = (columnName: string, typeName: string) => { + // Calculate the basic column header width. This allows for horizontal cell padding, + // the sorting button, the sort indicator, the sort index, and the border to be + // displayed, at a minimum. + const basicColumnHeaderWidth = + horizontalCellPadding + // Horizontal cell padding. + sortIndexWidth + // The sort index width. + 6 + // The sort index padding. + 20 + // The sort indicator width + SORTING_BUTTON_WIDTH + // The sorting button width. + 1; // +1 for the border. + + // If the column header is empty, return the basic column header width. + if (!columnName && !typeName) { + return basicColumnHeaderWidth; + } - // Calculate the editor font space width. - const editorFontSpaceWidth = FontMeasurements.readFontInfo( + // Create a canvas and create a 2D rendering context for it to measure text. + const canvas = window.document.createElement('canvas'); + const canvasRenderingContext2D = canvas.getContext('2d'); + + // If the 2D canvas rendering context couldn't be created, return the basic column + // header width. + if (!canvasRenderingContext2D) { + return basicColumnHeaderWidth; + } + + // Set the column name width. + let columnNameWidth; + if (!columnName) { + columnNameWidth = 0; + } else { + // Measure the column name width using the font of the column name exemplar. + const columnNameExemplarStyle = + DOM.getComputedStyle(columnNameExemplar.current); + canvasRenderingContext2D.font = columnNameExemplarStyle.font; + columnNameWidth = canvasRenderingContext2D.measureText(columnName).width; + } + + // Set the type name width. + let typeNameWidth; + if (!typeName) { + typeNameWidth = 0; + } else { + // Measure the type name width using the font of the type name exemplar. + const typeNameExemplarStyle = DOM.getComputedStyle(typeNameExemplar.current); + canvasRenderingContext2D.font = typeNameExemplarStyle.font; + typeNameWidth = canvasRenderingContext2D.measureText(typeName).width; + } + + // Calculate return the column header width. + return Math.ceil(Math.max(columnNameWidth, typeNameWidth) + basicColumnHeaderWidth); + }; + + // Get the editor font space width. + const { spaceWidth } = FontMeasurements.readFontInfo( window, BareFontInfo.createFromRawSettings( context.configurationService.getValue('editor'), PixelRatio.getInstance(window).value ) - ).spaceWidth; + ); - // Set the column value width calculator. Column value widths are calculated. - context.instance.tableDataDataGridInstance.setColumnValueWidthCalculator(length => - Math.ceil( - (editorFontSpaceWidth * length) + + context.instance.tableDataDataGridInstance.setWidthCalculators({ + columnHeaderWidthCalculator, + columnValueWidthCalculator: length => Math.ceil( + (spaceWidth * length) + horizontalCellPadding + - + 1 // +1 for the border. + 1 // For the border. ) - ); + }); // Add the onDidChangeConfiguration event handler. context.configurationService.onDidChangeConfiguration(configurationChangeEvent => { @@ -168,35 +168,30 @@ export const DataExplorer = () => { configurationChangeEvent.affectedKeys.has('editor.lineHeight') || configurationChangeEvent.affectedKeys.has('editor.letterSpacing') ) { - // Set the column value width calculator. - context.instance.tableDataDataGridInstance.setColumnValueWidthCalculator( - length => { - // Calculate the editor font space width. - const editorFontSpaceWidth = FontMeasurements.readFontInfo( - window, - BareFontInfo.createFromRawSettings( - context.configurationService.getValue('editor'), - PixelRatio.getInstance(window).value - ) - ).spaceWidth; - - // Calculate the column value width using the font editor font. - return Math.ceil( - (editorFontSpaceWidth * length) + - horizontalCellPadding + - + 1 // +1 for the border. - ); - } + // Get the editor font space width. + const { spaceWidth } = FontMeasurements.readFontInfo( + window, + BareFontInfo.createFromRawSettings( + context.configurationService.getValue('editor'), + PixelRatio.getInstance(window).value + ) ); + + context.instance.tableDataDataGridInstance.setWidthCalculators({ + columnHeaderWidthCalculator, + columnValueWidthCalculator: length => Math.ceil( + (spaceWidth * length) + + horizontalCellPadding + + 1 // For the border. + ) + }); } } }); // Return the cleanup function. return () => { - context.instance.tableDataDataGridInstance.setColumnHeaderWidthCalculator(undefined); - context.instance.tableDataDataGridInstance.setSortIndexWidthCalculator(undefined); - context.instance.tableDataDataGridInstance.setColumnValueWidthCalculator(undefined); + context.instance.tableDataDataGridInstance.setWidthCalculators(undefined); }; }, [context.configurationService, context.instance.tableDataDataGridInstance]); diff --git a/src/vs/workbench/browser/positronDataGrid/classes/dataGridInstance.ts b/src/vs/workbench/browser/positronDataGrid/classes/dataGridInstance.ts index 68289b9b9c2..44052bed07c 100644 --- a/src/vs/workbench/browser/positronDataGrid/classes/dataGridInstance.ts +++ b/src/vs/workbench/browser/positronDataGrid/classes/dataGridInstance.ts @@ -8,6 +8,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IDataColumn } from 'vs/workbench/browser/positronDataGrid/interfaces/dataColumn'; import { IColumnSortKey } from 'vs/workbench/browser/positronDataGrid/interfaces/columnSortKey'; import { AnchorPoint } from 'vs/workbench/browser/positronComponents/positronModalPopup/positronModalPopup'; +import { ILayoutEntry, LayoutManager } from 'vs/workbench/services/positronDataExplorer/common/layoutManager'; /** * ColumnHeaderOptions type. @@ -37,7 +38,7 @@ type RowHeaderOptions = | { * DefaultSizeOptions type. */ type DefaultSizeOptions = | { - readonly defaultColumnWidth?: number; + readonly defaultColumnWidth: number; readonly defaultRowHeight: number; }; @@ -60,9 +61,11 @@ type ColumnResizeOptions = | { type RowResizeOptions = | { readonly rowResize: false; readonly minimumRowHeight?: never; + readonly maximumRowHeight?: never; } | { readonly rowResize: true; readonly minimumRowHeight: number; + readonly maximumRowHeight: number; }; /** @@ -71,19 +74,23 @@ type RowResizeOptions = | { type ScrollbarOptions = | { readonly horizontalScrollbar: false; readonly verticalScrollbar: false; - readonly scrollbarWidth?: never; + readonly scrollbarThickness?: never; + readonly scrollbarOverscroll?: never; } | { readonly horizontalScrollbar: true; readonly verticalScrollbar: false; - readonly scrollbarWidth: number; + readonly scrollbarThickness: number; + readonly scrollbarOverscroll: number; } | { readonly horizontalScrollbar: false; readonly verticalScrollbar: true; - readonly scrollbarWidth: number; + readonly scrollbarThickness: number; + readonly scrollbarOverscroll: number; } | { readonly horizontalScrollbar: true; readonly verticalScrollbar: true; - readonly scrollbarWidth: number; + readonly scrollbarThickness: number; + readonly scrollbarOverscroll: number; }; /** @@ -137,6 +144,22 @@ type DataGridOptions = DefaultCursorOptions & SelectionOptions; +/** + * ColumnDescriptor interface. + */ +export interface ColumnDescriptor { + readonly columnIndex: number; + readonly left: number; +} + +/** + * RowDescriptor interface. + */ +export interface RowDescriptor { + readonly rowIndex: number; + readonly top: number; +} + /** * ExtendColumnSelectionBy enumeration. */ @@ -420,7 +443,7 @@ export class ColumnSortKeyDescriptor implements IColumnSortKey { private _columnIndex: number; /** - * Gets or sets the the sort order; true for ascending, false for descending. + * Gets or sets the sort order; true for ascending, false for descending. */ private _ascending: boolean; @@ -432,7 +455,7 @@ export class ColumnSortKeyDescriptor implements IColumnSortKey { * Constuctor. * @param sortIndex The sort index. * @param columnIndex The column index. - * @param ascending The the sort order; true for ascending, false for descending. + * @param ascending The sort order; true for ascending, false for descending. */ constructor(sortIndex: number, columnIndex: number, ascending: boolean) { this._sortIndex = sortIndex; @@ -518,7 +541,7 @@ export abstract class DataGridInstance extends Disposable { private readonly _rowHeadersResize: boolean; /** - * Gets a value which indicates whether to enable column resize. + * Gets a value which indicates whether column resize is enabled. */ private readonly _columnResize: boolean; @@ -538,7 +561,7 @@ export abstract class DataGridInstance extends Disposable { private readonly _defaultColumnWidth: number; /** - * Gets a value which indicates whether to enable row resize. + * Gets a value which indicates whether row resize is enabled. */ private readonly _rowResize: boolean; @@ -547,6 +570,11 @@ export abstract class DataGridInstance extends Disposable { */ private readonly _minimumRowHeight: number; + /** + * Gets the maximum row height. + */ + private readonly _maximumRowHeight: number; + /** * Gets the default row height. */ @@ -563,9 +591,14 @@ export abstract class DataGridInstance extends Disposable { private readonly _verticalScrollbar: boolean; /** - * Gets the scrollbar width. + * Gets the scrollbar thickness. */ - private readonly _scrollbarWidth: number; + private readonly _scrollbarThickness: number; + + /** + * Gets the scrollbar overscroll. + */ + private readonly _scrollbarOverscroll: number; /** * Gets a value which indicates whether to use the editor font to display data. @@ -632,14 +665,14 @@ export abstract class DataGridInstance extends Disposable { private _height = 0; /** - * Gets or sets the first column index. + * The horizontal scroll offset. */ - private _firstColumnIndex = 0; + protected _horizontalScrollOffset = 0; /** - * Gets or sets the first row index. + * The vertical scroll offset. */ - private _firstRowIndex = 0; + protected _verticalScrollOffset = 0; /** * Gets or sets the cursor column index. @@ -681,14 +714,14 @@ export abstract class DataGridInstance extends Disposable { //#region Protected Properties /** - * Gets the user-defined column widths. + * Gets the column layout manager. */ - protected readonly _userDefinedColumnWidths = new Map(); + protected readonly _columnLayoutManager: LayoutManager; /** - * Gets the user-defined row heights. + * Gets the row layout manager. */ - protected readonly _userDefinedRowHeights = new Map(); + protected readonly _rowLayoutManager: LayoutManager; /** * Gets the column sort keys. @@ -716,44 +749,59 @@ export abstract class DataGridInstance extends Disposable { // Call the base class's constructor. super(); - // Set the options. + // ColumnHeaderOptions. this._columnHeaders = options.columnHeaders || false; this._columnHeadersHeight = this._columnHeaders ? options.columnHeadersHeight ?? 0 : 0; + // RowHeaderOptions. this._rowHeaders = options.rowHeaders || false; this._rowHeadersWidth = this._rowHeaders ? options.rowHeadersWidth ?? 0 : 0; this._rowHeadersResize = this._rowHeaders ? options.rowHeadersResize ?? false : false; - this._defaultColumnWidth = options.defaultColumnWidth ?? 0; + // DefaultSizeOptions. + this._defaultColumnWidth = options.defaultColumnWidth; this._defaultRowHeight = options.defaultRowHeight; + // ColumnResizeOptions. this._columnResize = options.columnResize || false; this._minimumColumnWidth = options.minimumColumnWidth ?? this._defaultColumnWidth; this._maximumColumnWidth = options.maximumColumnWidth ?? this._defaultColumnWidth; + // RowResizeOptions. this._rowResize = options.rowResize || false; this._minimumRowHeight = options.minimumRowHeight ?? options.defaultRowHeight; + this._maximumRowHeight = options.maximumRowHeight ?? options.defaultRowHeight; + // ScrollbarOptions. this._horizontalScrollbar = options.horizontalScrollbar || false; this._verticalScrollbar = options.verticalScrollbar || false; - this._scrollbarWidth = options.scrollbarWidth ?? 0; + this._scrollbarThickness = options.scrollbarThickness ?? 0; + this._scrollbarOverscroll = options.scrollbarOverscroll ?? 0; + // DisplayOptions. this._useEditorFont = options.useEditorFont; this._automaticLayout = options.automaticLayout; this._rowsMargin = options.rowsMargin ?? 0; this._cellBorders = options.cellBorders ?? true; this._horizontalCellPadding = options.horizontalCellPadding ?? 0; + // CursorOptions. this._cursorInitiallyHidden = options.cursorInitiallyHidden ?? false; if (options.cursorInitiallyHidden) { this._cursorColumnIndex = -1; this._cursorRowIndex = -1; } + // DefaultCursorOptions. this._internalCursor = options.internalCursor ?? true; this._cursorOffset = this._internalCursor ? options.cursorOffset ?? 0 : 0; + // SelectionOptions. this._selection = options.selection ?? true; + + // Allocate and initialize the layout managers. + this._columnLayoutManager = new LayoutManager(this._defaultColumnWidth); + this._rowLayoutManager = new LayoutManager(this._defaultRowHeight); } //#endregion Constructor & Dispose @@ -796,7 +844,7 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets a value which indicates whether to enable column resize. + * Gets a value which indicates whether column resize is enabled. */ get columnResize() { return this._columnResize; @@ -824,7 +872,7 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets a value which indicates whether to enable row resize. + * Gets a value which indicates whether row resize is enabled */ get rowResize() { return this._rowResize; @@ -838,7 +886,14 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets the defailt row height. + * Gets the maximum row height. + */ + get maximumRowHeight() { + return this._maximumRowHeight; + } + + /** + * Gets the default row height. */ get defaultRowHeight() { return this._defaultRowHeight; @@ -861,8 +916,15 @@ export abstract class DataGridInstance extends Disposable { /** * Gets the scrollbar width. */ - get scrollbarWidth() { - return this._scrollbarWidth; + get scrollbarThickness() { + return this._scrollbarThickness; + } + + /** + * Gets the scrollbar overscroll. + */ + get scrollbarOverscroll() { + return this._scrollbarOverscroll; } /** @@ -942,28 +1004,69 @@ export abstract class DataGridInstance extends Disposable { */ abstract get rows(): number; + /** + * Gets the scroll width. + */ + get scrollWidth() { + return this._columnLayoutManager.size + this._scrollbarOverscroll; + } + + /** + * Gets the scroll height. + */ + get scrollHeight() { + return (this._rowsMargin * 2) + this._rowLayoutManager.size + this._scrollbarOverscroll; + } + + /** + * Gets the page width. + */ + get pageWidth() { + return this.layoutWidth; + } + + /** + * Gets the page height. + */ + get pageHeight() { + return this.layoutHeight; + } + /** * Gets the layout width. */ get layoutWidth() { // Calculate the layout width. - let layoutWidth = this._width - this._rowHeadersWidth; + let layoutWidth = this._width; + if (this.rowHeaders) { + layoutWidth -= this._rowHeadersWidth; + } if (this._verticalScrollbar) { - layoutWidth -= this._scrollbarWidth; + layoutWidth -= this._scrollbarThickness; } // Done. return layoutWidth; } + /** + * Gets the layout right. + */ + get layoutRight() { + return this.horizontalScrollOffset + this.layoutWidth; + } + /** * Gets the layout height. */ get layoutHeight() { // Calculate the layout height. - let layoutHeight = this._height - this._columnHeadersHeight; + let layoutHeight = this._height; + if (this.columnHeaders) { + layoutHeight -= this._columnHeadersHeight; + } if (this._horizontalScrollbar) { - layoutHeight -= this._scrollbarWidth; + layoutHeight -= this._scrollbarThickness; } // Done. @@ -971,39 +1074,17 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets the screen columns. + * Gets the layout bottom. */ - get screenColumns() { - return Math.ceil(this._width / this._minimumColumnWidth); + get layoutBottom() { + return this.verticalScrollOffset + this.layoutHeight; } /** - * Gets the visible columns. + * Gets the screen columns. */ - get visibleColumns() { - // Calculate the visible columns. - let visibleColumns = 0; - let columnIndex = this._firstColumnIndex; - let availableLayoutWidth = this.layoutWidth; - while (columnIndex < this.columns) { - // Get the column width. - const columnWidth = this.getColumnWidth(columnIndex); - - // If the column width would exceed the available layout width, break out of the loop. - if (columnWidth > availableLayoutWidth) { - break; - } - - // Increment the visible columns and the column index. - visibleColumns++; - columnIndex++; - - // Adjust the available layout width. - availableLayoutWidth -= columnWidth; - } - - // Done. - return Math.max(visibleColumns, 1); + get screenColumns() { + return Math.ceil(this._width / this._minimumColumnWidth); } /** @@ -1014,100 +1095,69 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets the visible rows. + * Gets the maximum horizontal scroll offset. */ - get visibleRows() { - // Calculate the visible rows. - let visibleRows = 0; - let rowIndex = this._firstRowIndex; - let availableLayoutHeight = this.layoutHeight; - while (rowIndex < this.rows) { - // Get the row height. - const rowHeight = this.getRowHeight(rowIndex); - - // If the row height would exceed the available layout height, break out of the loop. - if (rowHeight > availableLayoutHeight) { - break; - } - - // Increment the visible rows and the row index. - visibleRows++; - rowIndex++; - - // Adjust the available layout height. - availableLayoutHeight -= rowHeight; - } - - // Done. - return Math.max(visibleRows, 1); + get maximumHorizontalScrollOffset() { + // If the scroll width is less than or equal to the layout width, return 0; otherwise, + // calculate and return the maximum horizontal scroll offset. + return this.scrollWidth <= this.layoutWidth ? 0 : this.scrollWidth - this.layoutWidth; } /** - * Gets the maximum first column. + * Gets the maximum vertical scroll offset. */ - get maximumFirstColumnIndex() { - // When there are no columns, return 0. - if (!this.columns) { - return 0; - } + get maximumVerticalScrollOffset() { + // If the scroll height is less than or equal to the layout height, return 0; otherwise, + // calculate and return the maximum vertical scroll offset. + return this.scrollHeight <= this.layoutHeight ? 0 : this.scrollHeight - this.layoutHeight; + } - // Calculate the maximum first column by looking backward through the columns for the last - // column that fits. - let layoutWidth = this.layoutWidth - this.getColumnWidth(this.columns - 1); - let maximumFirstColumn = this.columns - 1; - for (let columnIndex = maximumFirstColumn - 1; columnIndex >= 0; columnIndex--) { - const columnWidth = this.getColumnWidth(columnIndex); - if (columnWidth < layoutWidth) { - layoutWidth -= columnWidth; - maximumFirstColumn--; - } else { - break; - } + /** + * Gets the first column. + */ + get firstColumn(): ColumnDescriptor | undefined { + // Get the first column layout entry. If it wasn't found, return undefined. + const layoutEntry = this._columnLayoutManager.findLayoutEntry(this.horizontalScrollOffset); + if (!layoutEntry) { + return undefined; } - // Done. - return maximumFirstColumn; + // Return the column descriptor for the first column. + return { + columnIndex: layoutEntry.index, + left: layoutEntry.start + }; } /** - * Gets the maximum first row. + * Gets the first row. */ - get maximumFirstRowIndex() { - // When there are no rows, return 0. - if (!this.rows) { - return 0; + get firstRow(): RowDescriptor | undefined { + // Get the first row layout entry. If it wasn't found, return undefined. + const layoutEntry = this._rowLayoutManager.findLayoutEntry(this.verticalScrollOffset); + if (!layoutEntry) { + return undefined; } - // Calculate the maximum first row by looking backward through the rows for the last row - // that fits. - let layoutHeight = this.layoutHeight - this.getRowHeight(this.rows - 1); - let maximumFirstRow = this.rows - 1; - for (let rowIndex = maximumFirstRow - 1; rowIndex >= 0; rowIndex--) { - const rowHeight = this.getRowHeight(rowIndex); - if (rowHeight < layoutHeight) { - layoutHeight -= rowHeight; - maximumFirstRow--; - } else { - break; - } - } - - // Done. - return maximumFirstRow; + // Return the row descriptor for the first row. + return { + rowIndex: layoutEntry.index, + top: layoutEntry.start + }; } /** - * Gets the first column index. + * Gets the horizontal scroll offset. */ - get firstColumnIndex() { - return this._firstColumnIndex; + get horizontalScrollOffset() { + return this._horizontalScrollOffset; } /** - * Gets the first row index. + * Gets the vertical scroll offset. */ - get firstRowIndex() { - return this._firstRowIndex; + get verticalScrollOffset() { + return this._verticalScrollOffset; } /** @@ -1170,35 +1220,72 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets the the width of a column. + * Gets a column descriptor. + * @param columnIndex The column index. + * @returns The column descriptor, if found; otherwise, undefined. + */ + getColumn(columnIndex: number): ColumnDescriptor | undefined { + // Get the column layout entry. If it wasn't found, return undefined. + const layoutEntry = this._columnLayoutManager.getLayoutEntry(columnIndex); + if (!layoutEntry) { + return undefined; + } + + // Return the column descriptor for the column. + return { + columnIndex: layoutEntry.index, + left: layoutEntry.start + }; + } + + /** + * Gets a row descriptor. + * @param columnIndex The row index. + * @returns The row descriptor, if found; otherwise, undefined. + */ + getRow(rowIndex: number): RowDescriptor | undefined { + // Get the row layout entry. If it wasn't found, return undefined. + const layoutEntry = this._rowLayoutManager.getLayoutEntry(rowIndex); + if (!layoutEntry) { + return undefined; + } + + // Return the row descriptor for the row. + return { + rowIndex: layoutEntry.index, + top: layoutEntry.start + }; + } + + /** + * Gets the width of a column. * @param columnIndex The column index. */ getColumnWidth(columnIndex: number): number { - const columnWidth = this._userDefinedColumnWidths.get(columnIndex); - if (columnWidth !== undefined) { - return columnWidth; - } else { - return this._defaultColumnWidth; + // Get the column layout entry. If it wasn't found, return 0. + const layoutEntry = this._columnLayoutManager.getLayoutEntry(columnIndex); + if (!layoutEntry) { + return 0; } + + // Return the column width. + return layoutEntry.size; } /** - * Sets the width of a column. + * Sets a column width. * @param columnIndex The column index. * @param columnWidth The column width. * @returns A Promise that resolves when the operation is complete. */ async setColumnWidth(columnIndex: number, columnWidth: number): Promise { - // Get the current column width. - const currentColumnWidth = this._userDefinedColumnWidths.get(columnIndex); - if (currentColumnWidth !== undefined) { - if (columnWidth === currentColumnWidth) { - return; - } + // If column resize is disabled, return. + if (!this._columnResize) { + return; } - // Set the column width. - this._userDefinedColumnWidths.set(columnIndex, columnWidth); + // Set the column width override. + this._columnLayoutManager.setLayoutOverride(columnIndex, columnWidth); // Fetch data. await this.fetchData(); @@ -1208,35 +1295,34 @@ export abstract class DataGridInstance extends Disposable { } /** - * Gets the the height of a row. + * Gets the height of a row. * @param rowIndex The row index. */ getRowHeight(rowIndex: number) { - const rowHeight = this._userDefinedRowHeights.get(rowIndex); - if (rowHeight !== undefined) { - return rowHeight; - } else { - return this._defaultRowHeight; + // Get the row layout entry. If it wasn't found, return 0. + const layoutEntry = this._rowLayoutManager.getLayoutEntry(rowIndex); + if (!layoutEntry) { + return undefined; } + + // Return the row height. + return layoutEntry.size; } /** - * Sets the the height of a row. + * Sets a row height. * @param rowIndex The row index. * @param rowHeight The row height. * @returns A Promise that resolves when the operation is complete. */ async setRowHeight(rowIndex: number, rowHeight: number): Promise { - // Get the current row height. - const currentRowHeight = this._userDefinedRowHeights.get(rowIndex); - if (currentRowHeight !== undefined) { - if (rowHeight === currentRowHeight) { - return; - } + // If row resize is disabled, return. + if (!this._rowResize) { + return; } - // Set the row height. - this._userDefinedRowHeights.set(rowIndex, rowHeight); + // Set the row height override. + this._rowLayoutManager.setLayoutOverride(rowIndex, rowHeight); // Fetch data. await this.fetchData(); @@ -1245,6 +1331,87 @@ export abstract class DataGridInstance extends Disposable { this._onDidUpdateEmitter.fire(); } + /** + * Scrolls the page up. + * @returns A Promise that resolves when the operation is complete. + */ + async scrollPageUp() { + // Get the first row layout entry for the vertical scroll offset. + const firstLayoutEntry = this._rowLayoutManager.findLayoutEntry(this.verticalScrollOffset); + if (firstLayoutEntry && firstLayoutEntry.index > 1) { + // Find the layout entry that will be to first layout entry for the previous page. + let lastFullyVisibleLayoutEntry: ILayoutEntry | undefined = undefined; + for (let index = firstLayoutEntry.index - 1; index >= 0; index--) { + // Get the layout entry. + const layoutEntry = this._rowLayoutManager.getLayoutEntry(index); + if (layoutEntry) { + if (layoutEntry.start >= this.verticalScrollOffset - this.layoutHeight) { + lastFullyVisibleLayoutEntry = layoutEntry; + } else { + // Set the vertical scroll offset. + this.setVerticalScrollOffset( + lastFullyVisibleLayoutEntry?.start ?? layoutEntry.start + ); + + // Fetch data. + await this.fetchData(); + + // Fire the onDidUpdate event. + this._onDidUpdateEmitter.fire(); + + // Done. + return; + } + } + } + } + + // Scroll to the top. + this.setVerticalScrollOffset(0); + await this.fetchData(); + this._onDidUpdateEmitter.fire(); + } + + /** + * Scrolls the page down. + * @returns A Promise that resolves when the operation is complete. + */ + async scrollPageDown() { + // Get the first row layout entry for the vertical scroll offset. + const firstLayoutEntry = this._rowLayoutManager.findLayoutEntry(this.verticalScrollOffset); + if (firstLayoutEntry && firstLayoutEntry.index < this.rows - 1) { + + // Find the layout entry that will be to first layout entry for the next page. + for (let index = firstLayoutEntry.index + 1; index < this.rows; index++) { + // Get the layout entry. + const layoutEntry = this._rowLayoutManager.getLayoutEntry(index); + if (layoutEntry) { + if (layoutEntry.end >= this.verticalScrollOffset + this.layoutHeight) { + // Set the vertical scroll offset. + this.setVerticalScrollOffset(Math.min( + layoutEntry.start, + this.maximumVerticalScrollOffset + )); + + // Fetch data. + await this.fetchData(); + + // Fire the onDidUpdate event. + this._onDidUpdateEmitter.fire(); + + // Done. + return; + } + } + } + } + + // Scroll to the bottom. + this.setVerticalScrollOffset(this.maximumVerticalScrollOffset); + await this.fetchData(); + this._onDidUpdateEmitter.fire(); + } + /** * Sets a column sort key. * @param columnIndex The column index. @@ -1336,7 +1503,7 @@ export abstract class DataGridInstance extends Disposable { async setRowHeadersWidth(rowHeadersWidth: number): Promise { // If the row headers width has changed, update it. if (rowHeadersWidth !== this._rowHeadersWidth) { - // Set the row headers width.. + // Set the row headers width. this._rowHeadersWidth = rowHeadersWidth; // Fetch data. @@ -1348,31 +1515,24 @@ export abstract class DataGridInstance extends Disposable { } /** - * Sets the screen size. + * Sets the size. * @param width The width. * @param height The height. * @returns A Promise that resolves when the operation is complete. */ - async setScreenSize(width: number, height: number): Promise { - // A flag that is set to true when the screen size changed. - let screenSizeChanged = false; - - // Set the width, if it changed. - if (width !== this._width) { + async setSize(width: number, height: number): Promise { + // If the size changed, optmize the vertical scroll offset, fetch data and fire the + // onDidUpdate event. + if (width !== this._width || height !== this._height) { + // Update the width and height. this._width = width; - this.optimizeFirstColumn(); - screenSizeChanged = true; - } - - // Set the height, if it changed. - if (height !== this._height) { this._height = height; - this.optimizeFirstRow(); - screenSizeChanged = true; - } - // If the screen size changed, fetch data and fire the onDidUpdate event. - if (screenSizeChanged) { + // Optimimize the vertical scroll offset. + if (this._verticalScrollOffset > this.maximumVerticalScrollOffset) { + this._verticalScrollOffset = this.maximumVerticalScrollOffset; + } + // Fetch data. await this.fetchData(); @@ -1382,16 +1542,22 @@ export abstract class DataGridInstance extends Disposable { } /** - * Sets the screen position. - * @param firstColumnIndex The first column index. - * @param firstRowIndex The first row index. + * Sets the scroll offsets. + * @param horizontalScrollOffset The horizontal scroll offset. + * @param verticalScrollOffset The vertical scroll offset. * @returns A Promise that resolves when the operation is complete. */ - async setScreenPosition(firstColumnIndex: number, firstRowIndex: number): Promise { - if (firstColumnIndex !== this._firstColumnIndex || firstRowIndex !== this._firstRowIndex) { + async setScrollOffsets( + horizontalScrollOffset: number, + verticalScrollOffset: number + ): Promise { + // If the screen position has changed, update the data grid. + if (horizontalScrollOffset !== this._horizontalScrollOffset || + verticalScrollOffset !== this._verticalScrollOffset + ) { // Set the screen position. - this._firstColumnIndex = firstColumnIndex; - this._firstRowIndex = firstRowIndex; + this._horizontalScrollOffset = horizontalScrollOffset; + this._verticalScrollOffset = verticalScrollOffset; // Fetch data. await this.fetchData(); @@ -1402,14 +1568,14 @@ export abstract class DataGridInstance extends Disposable { } /** - * Sets the first column. - * @param firstColumnIndex The first column index. + * Sets the horizontal scroll offset. + * @param horizontalScrollOffset The horizontal scroll offset. * @returns A Promise that resolves when the operation is complete. */ - async setFirstColumn(firstColumnIndex: number): Promise { - if (firstColumnIndex !== this._firstColumnIndex) { - // Set the first column index. - this._firstColumnIndex = firstColumnIndex; + async setHorizontalScrollOffset(horizontalScrollOffset: number): Promise { + if (horizontalScrollOffset !== this._horizontalScrollOffset) { + // Set the horizontal scroll offset. + this._horizontalScrollOffset = horizontalScrollOffset; // Fetch data. await this.fetchData(); @@ -1420,17 +1586,14 @@ export abstract class DataGridInstance extends Disposable { } /** - * Sets the first row. - * @param firstRowIndex The first row index. - * @param force A value which indicates whether to force the operation. + * Sets the vertical scroll offset. + * @param verticalScrollOffset The vertical scroll offset. * @returns A Promise that resolves when the operation is complete. */ - async setFirstRow(firstRowIndex: number, force: boolean = false): Promise { - // If the operation is being forced, or the first row has changed, set the first row and - // update the data grid. - if (force || firstRowIndex !== this._firstRowIndex) { - // Set the first row index. - this._firstRowIndex = firstRowIndex; + async setVerticalScrollOffset(verticalScrollOffset: number): Promise { + if (verticalScrollOffset !== this._verticalScrollOffset) { + // Set the vertical scroll offset. + this._verticalScrollOffset = verticalScrollOffset; // Fetch data. await this.fetchData(); @@ -1440,6 +1603,7 @@ export abstract class DataGridInstance extends Disposable { } } + /** * Sets the cursor position. * @param cursorColumnIndex The cursor column index. @@ -1501,8 +1665,51 @@ export abstract class DataGridInstance extends Disposable { * @returns A Promise that resolves when the operation is complete. */ async scrollToCell(columnIndex: number, rowIndex: number) { - await this.scrollToColumn(columnIndex); - await this.scrollToRow(rowIndex); + // Get the column layout entry. If it wasn't found, return. + const columnLayoutEntry = this._columnLayoutManager.getLayoutEntry(columnIndex); + if (!columnLayoutEntry) { + return; + } + + // Get the row layout entry. If it wasn't found, return. + const rowLayoutEntry = this._rowLayoutManager.getLayoutEntry(rowIndex); + if (!rowLayoutEntry) { + return; + } + + // Initialize the scroll offset updated flag. + let scrollOffsetUpdated = false; + + // If the column isn't visible, adjust the horizontal scroll offset to scroll to it. + if (columnLayoutEntry.start < this._horizontalScrollOffset) { + this._horizontalScrollOffset = columnLayoutEntry.start; + scrollOffsetUpdated = true; + } else if (columnLayoutEntry.end > this._horizontalScrollOffset + this.layoutWidth) { + this._horizontalScrollOffset = columnIndex === this.columns - 1 ? + this._horizontalScrollOffset = this.maximumHorizontalScrollOffset : + this._horizontalScrollOffset = columnLayoutEntry.end - this.layoutWidth; + scrollOffsetUpdated = true; + } + + // If the row isn't visible, adjust the vertical scroll offset to scroll to it. + if (rowLayoutEntry.start < this._verticalScrollOffset) { + this._verticalScrollOffset = rowLayoutEntry.start; + scrollOffsetUpdated = true; + } else if (rowLayoutEntry.end > this._verticalScrollOffset + this.layoutHeight) { + this._verticalScrollOffset = rowIndex === this.rows - 1 ? + this._verticalScrollOffset = this.maximumVerticalScrollOffset : + this._verticalScrollOffset = rowLayoutEntry.end - this.layoutHeight; + scrollOffsetUpdated = true; + } + + // If scroll offset was updated, fetch data and fire the onDidUpdate event. + if (scrollOffsetUpdated) { + // Fetch data. + await this.fetchData(); + + // Fire the onDidUpdate event. + this._onDidUpdateEmitter.fire(); + } } /** @@ -1511,12 +1718,17 @@ export abstract class DataGridInstance extends Disposable { * @returns A Promise that resolves when the operation is complete. */ async scrollToColumn(columnIndex: number): Promise { - if (columnIndex < this._firstColumnIndex) { - await this.setFirstColumn(columnIndex); - } else if (columnIndex >= this._firstColumnIndex + this.visibleColumns) { - do { - await this.setFirstColumn(this._firstColumnIndex + 1); - } while (columnIndex >= this._firstColumnIndex + this.visibleColumns); + // Get the column layout entry. If it wasn't found, return. + const columnLayoutEntry = this._columnLayoutManager.getLayoutEntry(columnIndex); + if (!columnLayoutEntry) { + return; + } + + // If the column isn't visible, scroll to it. + if (columnLayoutEntry.start < this._horizontalScrollOffset) { + await this.setHorizontalScrollOffset(columnLayoutEntry.start); + } else if (columnLayoutEntry.end > this._horizontalScrollOffset + this.layoutWidth) { + await this.setHorizontalScrollOffset(columnLayoutEntry.end - this.layoutWidth); } } @@ -1525,12 +1737,17 @@ export abstract class DataGridInstance extends Disposable { * @param rowIndex The row index. */ async scrollToRow(rowIndex: number) { - if (rowIndex < this.firstRowIndex) { - await this.setFirstRow(rowIndex); - } else if (rowIndex >= this.firstRowIndex + this.visibleRows) { - do { - await this.setFirstRow(this.firstRowIndex + 1); - } while (rowIndex >= this.firstRowIndex + this.visibleRows); + // Get the row layout entry. If it wasn't found, return. + const rowLayoutEntry = this._rowLayoutManager.getLayoutEntry(rowIndex); + if (!rowLayoutEntry) { + return; + } + + // If the row isn't visible, scroll to it. + if (rowLayoutEntry.start < this._verticalScrollOffset) { + await this.setVerticalScrollOffset(rowLayoutEntry.start); + } else if (rowLayoutEntry.end > this._verticalScrollOffset + this.layoutHeight) { + await this.setVerticalScrollOffset(rowLayoutEntry.end - this.layoutHeight); } } @@ -1656,7 +1873,7 @@ export abstract class DataGridInstance extends Disposable { const adjustCursor = async (columnIndex: number) => { // Adjust the cursor. this._cursorColumnIndex = columnIndex; - this._cursorRowIndex = this._firstRowIndex; + this._cursorRowIndex = this.firstRow?.rowIndex ?? 0; }; // Process the selection based on selection type. @@ -1779,7 +1996,7 @@ export abstract class DataGridInstance extends Disposable { */ const adjustCursor = async (rowIndex: number) => { // Adjust the cursor. - this._cursorColumnIndex = this._firstColumnIndex; + this._cursorColumnIndex = this.firstColumn?.columnIndex ?? 0; this._cursorRowIndex = rowIndex; }; @@ -2459,12 +2676,11 @@ export abstract class DataGridInstance extends Disposable { //#region Protected Methods /** - * Performs a reset of the data grid. + * Performs a soft reset of the data grid. */ protected softReset() { - // Reset the display. - this._firstColumnIndex = 0; - this._firstRowIndex = 0; + this._horizontalScrollOffset = 0; + this._verticalScrollOffset = 0; if (this._cursorInitiallyHidden) { this._cursorColumnIndex = -1; this._cursorRowIndex = -1; @@ -2478,7 +2694,7 @@ export abstract class DataGridInstance extends Disposable { } /** - * Resets the selection. + * Resets the selection of the data grid. */ protected resetSelection() { this._cellSelectionRange = undefined; @@ -2492,90 +2708,6 @@ export abstract class DataGridInstance extends Disposable { //#region Private Methods - /** - * Optimizes the first column. - */ - private optimizeFirstColumn() { - // If the waffle isn't scrolled horizontally, return. - if (!this.firstColumnIndex) { - return; - } - - // Calculate the layout width. - let layoutWidth = this.layoutWidth; - for (let i = this.firstColumnIndex; i < this.columns; i++) { - // Adjust the layout width. - layoutWidth -= this.getColumnWidth(i); - - // If the layout width has been exhausted, return. - if (layoutWidth <= 0) { - return; - } - } - - // See if we can optimize the first column. - let firstColumnIndex: number | undefined = undefined; - for (let i = this.firstColumnIndex - 1; i >= 0 && layoutWidth > 0; i--) { - // Get the column width. - const columnWidth = this.getColumnWidth(i); - - // If the column will fit, make it the first column index. - if (columnWidth <= layoutWidth) { - firstColumnIndex = i; - } - - // Adjust the layout width. - layoutWidth -= columnWidth; - } - - // Set the first column, if it was adjusted. - if (firstColumnIndex) { - this._firstColumnIndex = firstColumnIndex; - } - } - - /** - * Optimizes the first row. - */ - private optimizeFirstRow() { - // If the waffle isn't scrolled vertically, return. - if (!this.firstRowIndex) { - return; - } - - // Calculate the layout height. - let layoutHeight = this.layoutHeight; - for (let i = this.firstRowIndex; i < this.rows; i++) { - // Adjust the layout height. - layoutHeight -= this.getRowHeight(i); - - // If the layout height has been exhausted, return. - if (layoutHeight <= 0) { - return; - } - } - - // See if we can optimize the first column. - let firstRowIndex: number | undefined = undefined; - for (let i = this.firstRowIndex - 1; i >= 0 && layoutHeight > 0; i--) { - // Get the row height. - const rowHeight = this.getRowHeight(i); - - // If the row will fit, make it the first row index. - if (rowHeight <= layoutHeight) { - firstRowIndex = i; - } - - // Adjust the layout height. - layoutHeight -= rowHeight; - } - - // Set the first row, if it was adjusted. - if (firstRowIndex) { - this._firstRowIndex = firstRowIndex; - } - } - /** * Sorts the data. * @returns A Promise that resolves when the data is sorted. diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.css b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.css index 68303b99670..d3169511241 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.css +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.css @@ -115,7 +115,7 @@ .content .sort-indicator .sort-index { - margin: 0 3px; + margin: 0 3px; /* If this value is changed, columnHeaderWidthCalculator must be updated. */ color: var(--vscode-positronDataGrid-sortIndexForeground); font-size: var(--positron-data-grid-column-header-sort-index-font-size); font-weight: var(--positron-data-grid-column-header-sort-index-font-weight); @@ -126,8 +126,8 @@ .content .sort-button { z-index: 1; - width: 20px; - height: 20px; + width: 20px; /* If this value is changed, columnHeaderWidthCalculator must be updated. */ + height: 20px; /* If this value is changed, columnHeaderWidthCalculator must be updated. */ display: flex; cursor: pointer; border-radius: 4px; diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.tsx index 246f4eb0ce7..0ac3ac34749 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader.tsx @@ -177,8 +177,8 @@ export const DataGridColumnHeader = (props: DataGridColumnHeaderProps) => { maximumWidth: context.instance.maximumColumnWidth, columnsWidth: context.instance.getColumnWidth(props.columnIndex) })} - onResize={async width => - await context.instance.setColumnWidth(props.columnIndex, width) + onResize={async columnWidth => + await context.instance.setColumnWidth(props.columnIndex, columnWidth) } /> } diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeaders.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeaders.tsx index 243f4e56e35..49d61d3f5db 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeaders.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridColumnHeaders.tsx @@ -32,33 +32,27 @@ export const DataGridColumnHeaders = (props: DataGridColumnHeadersProps) => { // Context hooks. const context = usePositronDataGridContext(); - // Render the visible column headers. - const columnHeaders: JSX.Element[] = []; - for (let columnIndex = context.instance.firstColumnIndex, left = 0; - columnIndex < context.instance.columns && left < props.width; - columnIndex++ + // Create the data grid column headers. + const dataGridColumnHeaders: JSX.Element[] = []; + for ( + let columnDescriptor = context.instance.firstColumn; + columnDescriptor && columnDescriptor.left < context.instance.layoutRight; + columnDescriptor = context.instance.getColumn(columnDescriptor.columnIndex + 1) ) { - // Access the column. - const column = context.instance.column(columnIndex); - - // Push the column header component. - columnHeaders.push( + dataGridColumnHeaders.push( ); - - // Adjust the left offset for the next column. - left += context.instance.getColumnWidth(columnIndex); } // Render. return (
- {columnHeaders} + {dataGridColumnHeaders}
); }; diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridRow.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridRow.tsx index a05e2b450a7..16c42fba25e 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridRow.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridRow.tsx @@ -31,23 +31,20 @@ export const DataGridRow = (props: DataGridRowProps) => { // Context hooks. const context = usePositronDataGridContext(); - // Render the visible row cells. - const rowCells: JSX.Element[] = []; - for (let columnIndex = context.instance.firstColumnIndex, left = 0; - columnIndex < context.instance.columns && left < props.width; - columnIndex++ + // Create the data grid column headers. + const dataGridRowCells: JSX.Element[] = []; + for (let columnDescriptor = context.instance.firstColumn; + columnDescriptor && columnDescriptor.left < context.instance.layoutRight; + columnDescriptor = context.instance.getColumn(columnDescriptor.columnIndex + 1) ) { - rowCells.push( + dataGridRowCells.push( ); - - // Adjust the left offset for the next column. - left += context.instance.getColumnWidth(columnIndex); } // Render. @@ -59,7 +56,7 @@ export const DataGridRow = (props: DataGridRowProps) => { height: context.instance.getRowHeight(props.rowIndex) }} > - {rowCells} + {dataGridRowCells} ); }; diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowCell.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowCell.tsx index 57ac21f1112..54bb6d793e9 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowCell.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowCell.tsx @@ -180,8 +180,8 @@ export const DataGridRowCell = (props: DataGridRowCellProps) => { maximumWidth: context.instance.maximumColumnWidth, columnsWidth: context.instance.getColumnWidth(props.columnIndex) })} - onResize={async width => - await context.instance.setColumnWidth(props.columnIndex, width) + onResize={async columnWidth => + await context.instance.setColumnWidth(props.columnIndex, columnWidth) } /> } @@ -190,10 +190,10 @@ export const DataGridRowCell = (props: DataGridRowCellProps) => { onBeginResize={() => ({ minimumHeight: context.instance.minimumRowHeight, maximumHeight: 90, - startingHeight: context.instance.getRowHeight(props.rowIndex) + startingHeight: context.instance.getRowHeight(props.rowIndex)! })} - onResize={async height => - await context.instance.setRowHeight(props.rowIndex, height) + onResize={async rowHeight => + await context.instance.setRowHeight(props.rowIndex, rowHeight) } /> } diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeader.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeader.tsx index 132a37dbc30..0d9899256f5 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeader.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeader.tsx @@ -130,10 +130,10 @@ export const DataGridRowHeader = (props: DataGridRowHeaderProps) => { onBeginResize={() => ({ minimumHeight: context.instance.minimumRowHeight, maximumHeight: 90, - startingHeight: context.instance.getRowHeight(props.rowIndex) + startingHeight: context.instance.getRowHeight(props.rowIndex)! })} - onResize={async height => - await context.instance.setRowHeight(props.rowIndex, height) + onResize={async rowHeight => + await context.instance.setRowHeight(props.rowIndex, rowHeight) } /> } diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.css b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.css index 224749424dd..ff4d1be8b0d 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.css +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .data-grid-row-headers { - /* overflow: hidden; */ + overflow: hidden; position: relative; grid-row: waffle / end-waffle; grid-column: headers / waffle; diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.tsx index 3cce8380810..5f036d14f9e 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders.tsx @@ -29,25 +29,25 @@ export const DataGridRowHeaders = (props: DataGridRowHeadersProps) => { // Context hooks. const context = usePositronDataGridContext(); - // Render the row headers. - const rowHeaders: JSX.Element[] = []; - for (let rowIndex = context.instance.firstRowIndex, top = 0; - rowIndex < context.instance.rows && top < props.height; - rowIndex++ + // Create the data grid rows headers. + const dataGridRowHeaders: JSX.Element[] = []; + for (let rowDescriptor = context.instance.firstRow; + rowDescriptor && rowDescriptor.top < context.instance.layoutBottom; + rowDescriptor = context.instance.getRow(rowDescriptor.rowIndex + 1) ) { - // Push the row header component. - rowHeaders.push( - + dataGridRowHeaders.push( + ); - - // Adjust the top offset for the next row. - top += context.instance.getRowHeight(rowIndex); } // Render. return (
- {rowHeaders} + {dataGridRowHeaders}
); }; diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.css b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.css index b080c36db78..377068a00fd 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.css +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.css @@ -3,51 +3,40 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -.data-grid -.data-grid-scrollbar { +.data-grid .data-grid-scrollbar { /* The Monaco sash is at a z-index of 35, so go lower than that. */ z-index: 26; /* contain: strict; */ position: absolute; box-sizing: border-box; - /* Makes some devices run their hardware acceleration. */ - /* transform: translate3d(0px, 0px, 0px); */ background-color: var(--vscode-positronDataGrid-background); } -.data-grid -.data-grid-scrollbar.vertical { +.data-grid .data-grid-scrollbar.vertical { top: -1px; right: 0; border-top: 1px solid var(--vscode-positronDataGrid-border); border-left: 1px solid var(--vscode-positronDataGrid-border); } -.data-grid -.data-grid-scrollbar.horizontal { +.data-grid .data-grid-scrollbar.horizontal { left: -1px; bottom: 0; border-top: 1px solid var(--vscode-positronDataGrid-border); border-left: 1px solid var(--vscode-positronDataGrid-border); } -.data-grid -.data-grid-scrollbar -.data-grid-scrollbar-slider { +.data-grid .data-grid-scrollbar .data-grid-scrollbar-slider { position: absolute; background-color: var(--vscode-scrollbarSlider-background); } -.data-grid -.data-grid-scrollbar -.data-grid-scrollbar-slider.vertical { +.data-grid .data-grid-scrollbar .data-grid-scrollbar-slider.vertical { right: 0; position: absolute; } -.data-grid -.data-grid-scrollbar -.data-grid-scrollbar-slider.horizontal { +.data-grid .data-grid-scrollbar .data-grid-scrollbar-slider.horizontal { bottom: 0; position: absolute; } diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.tsx index d603cd2b005..3e7ca2e9563 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbar.tsx @@ -23,6 +23,16 @@ const MIN_SLIDER_SIZE = 20; * DataGridScrollbarProps interface. */ interface DataGridScrollbarProps { + /** + * Gets the container width for the scrollbar. + */ + readonly containerWidth: number; + + /** + * Gets the container height for the scrollbar. + */ + readonly containerHeight: number; + /** * Gets the orientation of the scrollbar. */ @@ -34,46 +44,47 @@ interface DataGridScrollbarProps { readonly bothScrollbarsVisible: boolean; /** - * Gets the scrollbar width. For a vertical scrollbar, this is the horizontal width. For a - * horizontal scrollbar, this is the vertical height. + * Gets the scrollbar thickness. For a vertical scrollbar, this is the scrollbar width. For a + * horizontal scrollbar, this is the scrollbar height. */ - readonly scrollbarWidth: number; + readonly scrollbarThickness: number; /** - * Gets the container width for the scrollbar. + * Gets the scroll size. For a vertical scrollbar, this is the height of the scrollable + * content. For a horizontal scrollbar, this is the width of the scrollable content. */ - readonly containerWidth: number; + readonly scrollSize: number; /** - * Gets the container height for the scrollbar. + * Gets the layout size. For a vertical scrollbar, this is the visible height of the content. + * For a horizontal scrollbar, this is the visible width of the content. */ - readonly containerHeight: number; + readonly layoutSize: number; /** - * Gets the number of entries being scrolled. + * Gets the page size. For a vertical scrollbar, this is the height of a page. For a horizontal + * scrollbar, this is the width of a page. */ - readonly entries: number; + readonly pageSize: number; /** - * Gets the number of visible entries. + * Gets the scroll offset. For a vertical scrollbar, this is the top position of the scrollbar. + * For a horizontal scrollbar, this is the left position of the scrollbar. */ - readonly visibleEntries: number; + readonly scrollOffset: number; /** - * Gets the first entry. + * Gets the maximum scroll offset. For a vertical scrollbar, this is the maximum top position of + * the scrollbar. For a horizontal scrollbar, this is the maximum left position of the + * scrollbar. */ - readonly firstEntry: number; + readonly maximumScrollOffset: () => number; /** - * Gets the maximum first entry. + * Scroll offset changed callback. + * @param scrollOffset The scroll offset. */ - readonly maximumFirstEntry: number; - - /** - * First entry changed callback. - * @param firstEntry The first entry. - */ - readonly onDidChangeFirstEntry: (firstEntry: number) => void; + readonly onDidChangeScrollOffset: (scrollOffset: number) => void; } /** @@ -81,15 +92,15 @@ interface DataGridScrollbarProps { */ interface ScrollbarState { /** - * Gets the scrollbar size. For a vertical scrollbar, this is the scrollbar height. For a - * horizontal scrollbar, this is the scrollbar width. + * Gets a value which indicates whether the scrollbar is disabled. */ - readonly scrollbarSize: number; + readonly scrollbarDisabled: boolean; /** - * Gets a value which indicates whether the scrollbar is disabled. + * Gets the scrollbar length. For a vertical scrollbar, this is the scrollbar height. For a + * horizontal scrollbar, this is the scrollbar width. */ - readonly scrollbarDisabled: boolean; + readonly scrollbarLength: number; /** * Gets the slider size. For a vertical scrollbar, this is the slider height. For a horizontal @@ -102,13 +113,6 @@ interface ScrollbarState { * For a horizontal scrollbar, this is the left position of the slider. */ readonly sliderPosition: number; - - /** - * Gets a value which indicates whether to preserve slider position. This is set to true when - * the user has directly positioned the scrollbar before the onDidChangeFirstEntry callback is - * called. - */ - readonly preserveSliderPosition: boolean; } /** @@ -118,12 +122,11 @@ interface ScrollbarState { */ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { // State hooks. - const [scrollbarState, setScrollbarState] = useState({ - scrollbarSize: 0, + const [state, setState] = useState({ scrollbarDisabled: true, + scrollbarLength: 0, sliderSize: 0, - sliderPosition: 0, - preserveSliderPosition: false + sliderPosition: 0 }); /** @@ -131,62 +134,45 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { */ useLayoutEffect(() => { // Update the scrollbar state. - setScrollbarState(previousScrollbarState => { - // Calculate the scrollbar size. - let scrollbarSize = props.orientation === 'vertical' ? + setState((): ScrollbarState => { + // Calculate the scrollbar length. + let scrollbarLength = props.orientation === 'vertical' ? props.containerHeight : props.containerWidth; if (props.bothScrollbarsVisible) { - scrollbarSize -= props.scrollbarWidth; + scrollbarLength -= props.scrollbarThickness; } - // If the scrollbar isn't necessary, return. - if (props.visibleEntries >= props.entries && props.firstEntry === 0) { + // If the scrollbar isn't necessary, disable it and return. + if (props.scrollOffset === 0 && props.maximumScrollOffset() === 0) { return { - scrollbarSize, scrollbarDisabled: true, + scrollbarLength, sliderSize: 0, - sliderPosition: 0, - preserveSliderPosition: false + sliderPosition: 0 }; } // Calculate the slider size. - const sliderSize = pinToRange( - (props.visibleEntries / props.entries) * scrollbarSize, - MIN_SLIDER_SIZE, - scrollbarSize + const sliderSize = Math.max( + scrollbarLength / props.scrollSize * props.layoutSize, + MIN_SLIDER_SIZE ); // Calculate the slider position. - let sliderPosition: number; - if (previousScrollbarState.preserveSliderPosition) { - sliderPosition = scrollbarState.sliderPosition; - } else { - if (props.firstEntry === 0) { - sliderPosition = 0; - } else if (props.firstEntry + props.visibleEntries >= props.entries) { - sliderPosition = scrollbarSize - sliderSize; - } else { - sliderPosition = pinToRange( - (scrollbarSize - sliderSize) * - (props.firstEntry / (props.entries - props.visibleEntries)), - 0, - scrollbarSize - sliderSize - ); - } - } + const sliderPosition = + props.scrollOffset / props.maximumScrollOffset() * + (scrollbarLength - sliderSize); // Update the scrollbar state. return { - scrollbarSize, scrollbarDisabled: false, + scrollbarLength, sliderSize, - sliderPosition, - preserveSliderPosition: false + sliderPosition }; }); - }, [props]); + }, [props, state.sliderPosition]); /** * onMouseDown handler. This handles onMouseDown in the scrollbar (i.e. not in the slider). @@ -194,7 +180,7 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { */ const mouseDownHandler = (e: MouseEvent) => { // If the scrollbar is disabled, return. - if (scrollbarState.scrollbarDisabled) { + if (state.scrollbarDisabled) { return; } @@ -206,32 +192,16 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { e.clientY - boundingClientRect.y : e.clientX - boundingClientRect.x; - // Calculate the slider position so that it is centered on the mouse position in the - // scrollbar. - const sliderPosition = pinToRange( - mousePosition - (scrollbarState.sliderSize / 2), - 0, - scrollbarState.scrollbarSize - scrollbarState.sliderSize - ); - - // Set the scrollbar state. - setScrollbarState(previousScrollbarState => { - return { - ...previousScrollbarState, - sliderPosition, - preserveSliderPosition: true - }; - }); - - // Calculate the first entry. - const firstEntry = Math.min(Math.trunc( - (props.entries - props.visibleEntries) * - sliderPosition / (scrollbarState.scrollbarSize - scrollbarState.sliderSize)), - props.maximumFirstEntry - ); - - // Change the first entry. - props.onDidChangeFirstEntry(firstEntry); + // If the mouse is above the slider, page up. If the mouse is below the slider, page down. + if (mousePosition < state.sliderPosition) { + props.onDidChangeScrollOffset( + Math.max(props.scrollOffset - props.pageSize, 0) + ); + } else if (mousePosition > state.sliderPosition + state.sliderSize) { + props.onDidChangeScrollOffset( + Math.min(props.scrollOffset + props.pageSize, props.maximumScrollOffset()) + ); + } }; /** @@ -241,7 +211,7 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { */ const pointerDownHandler = (e: React.PointerEvent) => { // If the scrollbar is disabled, return. - if (scrollbarState.scrollbarDisabled) { + if (state.scrollbarDisabled) { return; } @@ -256,7 +226,7 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { // Setup the drag state. const target = DOM.getWindow(e.currentTarget).document.body; - const startingSliderPosition = scrollbarState.sliderPosition; + const startingSliderPosition = state.sliderPosition; const startingMousePosition = props.orientation === 'vertical' ? e.clientY : e.clientX; /** @@ -297,27 +267,26 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { const sliderPosition = pinToRange( startingSliderPosition + sliderDelta - startingMousePosition, 0, - scrollbarState.scrollbarSize - scrollbarState.sliderSize + state.scrollbarLength - state.sliderSize ); // Set the scrollbar state. - setScrollbarState(previousScrollbarState => { + setState((previousScrollbarState): ScrollbarState => { return { ...previousScrollbarState, - sliderPosition, - preserveSliderPosition: true + sliderPosition }; }); - // Calculate the first entry. - const firstEntry = Math.min(Math.trunc( - (props.entries - props.visibleEntries) * - sliderPosition / (scrollbarState.scrollbarSize - scrollbarState.sliderSize)), - props.maximumFirstEntry + // Calculate the slider percent. + const sliderPercent = pinToRange( + sliderPosition / (state.scrollbarLength - state.sliderSize), + 0, + 1 ); - // Change the first entry. - props.onDidChangeFirstEntry(firstEntry); + // Call the onDidChangeScrollOffset callback. + props.onDidChangeScrollOffset(props.maximumScrollOffset() * sliderPercent); }; // Set the capture target of future pointer events to be the current target and add our @@ -329,24 +298,24 @@ export const DataGridScrollbar = (props: DataGridScrollbarProps) => { // Set the scrollbar style. const scrollbarStyle: CSSProperties = props.orientation === 'vertical' ? { - width: props.scrollbarWidth, - bottom: props.bothScrollbarsVisible ? props.scrollbarWidth : 0, + width: props.scrollbarThickness, + bottom: props.bothScrollbarsVisible ? props.scrollbarThickness : 0, } : { - height: props.scrollbarWidth, - right: props.bothScrollbarsVisible ? props.scrollbarWidth : 0 + height: props.scrollbarThickness, + right: props.bothScrollbarsVisible ? props.scrollbarThickness : 0 }; // Set the slider style. const sliderStyle: CSSProperties = props.orientation === 'vertical' ? { - top: scrollbarState.sliderPosition, + top: state.sliderPosition, // -1 to not overlap border. - width: props.scrollbarWidth - 1, - height: scrollbarState.sliderSize + width: props.scrollbarThickness - 1, + height: state.sliderSize } : { - left: scrollbarState.sliderPosition, - width: scrollbarState.sliderSize, + left: state.sliderPosition, + width: state.sliderSize, // -1 to not overlap border. - height: props.scrollbarWidth - 1 + height: props.scrollbarThickness - 1 }; // Render. diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbarCorner.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbarCorner.tsx index 0c1a8308680..bef504c9022 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbarCorner.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridScrollbarCorner.tsx @@ -31,8 +31,8 @@ export const DataGridScrollbarCorner = (props: DataGridScrollbarCornerProps) =>
diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.css b/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.css index c34b1956a8a..fba8b1c85be 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.css +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.css @@ -12,6 +12,8 @@ overflow: hidden; position: relative; box-sizing: border-box; + /* Makes some devices run their hardware acceleration. */ + transform: translate3d(0px, 0px, 0px); grid-template-rows: [headers] min-content [waffle] 1fr [end-waffle]; grid-template-columns: [headers] min-content [waffle] 1fr [end-waffle]; } diff --git a/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.tsx b/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.tsx index 30227d3e6c7..336d5ce8e8f 100644 --- a/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.tsx +++ b/src/vs/workbench/browser/positronDataGrid/components/dataGridWaffle.tsx @@ -17,19 +17,14 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { pinToRange } from 'vs/base/common/positronUtilities'; import { editorFontApplier } from 'vs/workbench/browser/editorFontApplier'; import { DataGridRow } from 'vs/workbench/browser/positronDataGrid/components/dataGridRow'; -import { DataGridScrollbar } from 'vs/workbench/browser/positronDataGrid/components/dataGridScrollbar'; import { DataGridRowHeaders } from 'vs/workbench/browser/positronDataGrid/components/dataGridRowHeaders'; import { usePositronDataGridContext } from 'vs/workbench/browser/positronDataGrid/positronDataGridContext'; import { DataGridCornerTopLeft } from 'vs/workbench/browser/positronDataGrid/components/dataGridCornerTopLeft'; import { DataGridColumnHeaders } from 'vs/workbench/browser/positronDataGrid/components/dataGridColumnHeaders'; import { DataGridScrollbarCorner } from 'vs/workbench/browser/positronDataGrid/components/dataGridScrollbarCorner'; +import { DataGridScrollbar } from 'vs/workbench/browser/positronDataGrid/components/dataGridScrollbar'; import { ExtendColumnSelectionBy, ExtendRowSelectionBy } from 'vs/workbench/browser/positronDataGrid/classes/dataGridInstance'; -/** - * Constants. - */ -const MOUSE_WHEEL_SENSITIVITY = 50; - /** * DataGridWaffle component. * @param ref The foreard ref. @@ -51,8 +46,6 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { const [height, setHeight] = useState(0); const [, setRenderMarker] = useState(generateUuid()); const [lastWheelEvent, setLastWheelEvent] = useState(0); - const [wheelDeltaX, setWheelDeltaX] = useState(0); - const [wheelDeltaY, setWheelDeltaY] = useState(0); // Main useEffect. This is where we set up event handlers. useEffect(() => { @@ -90,7 +83,7 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { */ const setScreenSize = async (width: number, height: number) => { // Set the screen size. - await context.instance.setScreenSize(width, height); + await context.instance.setSize(width, height); }; // Set the initial screen size. @@ -196,14 +189,14 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { // top left. if (isMacintosh ? e.metaKey : e.ctrlKey) { context.instance.clearSelection(); - await context.instance.setScreenPosition(0, 0); + await context.instance.setScrollOffsets(0, 0); context.instance.setCursorPosition(0, 0); return; } // Home clears the selection and positions the screen and cursor to the left. context.instance.clearSelection(); - await context.instance.setFirstColumn(0); + await context.instance.setHorizontalScrollOffset(0); context.instance.setCursorColumn(0); break; } @@ -233,9 +226,9 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { // bottom right. if (isMacintosh ? e.metaKey : e.ctrlKey) { context.instance.clearSelection(); - await context.instance.setScreenPosition( - context.instance.maximumFirstColumnIndex, - context.instance.maximumFirstRowIndex + await context.instance.setScrollOffsets( + context.instance.maximumHorizontalScrollOffset, + context.instance.maximumVerticalScrollOffset ); context.instance.setCursorPosition( context.instance.columns - 1, @@ -246,7 +239,7 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { // End clears the selection and positions the screen and cursor to the left. context.instance.clearSelection(); - await context.instance.setFirstColumn(context.instance.maximumFirstColumnIndex); + await context.instance.setHorizontalScrollOffset(context.instance.maximumHorizontalScrollOffset); context.instance.setCursorColumn(context.instance.columns - 1); break; } @@ -273,14 +266,11 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { } // PageUp clears the selection and moves up by one page, positioning the cursor at - // the top left of the page. + // the top of the page. context.instance.clearSelection(); - const firstRowIndex = Math.max( - context.instance.firstRowIndex - (e.altKey ? context.instance.visibleRows * 10 : context.instance.visibleRows), - 0 - ); - await context.instance.setFirstRow(firstRowIndex); - context.instance.setCursorRow(firstRowIndex); + + // Scroll page up. + context.instance.scrollPageUp(); break; } @@ -306,14 +296,11 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { } // PageDown clears the selection and moves down by one page, positioning the cursor - // at the bottom left of the page. + // at the top of the page context.instance.clearSelection(); - const firstRowIndex = Math.min( - context.instance.firstRowIndex + (e.altKey ? context.instance.visibleRows * 10 : context.instance.visibleRows), - context.instance.maximumFirstRowIndex - ); - await context.instance.setFirstRow(firstRowIndex); - context.instance.setCursorRow(firstRowIndex); + + // Scroll page down. + context.instance.scrollPageDown(); break; } @@ -473,72 +460,48 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { let deltaX = e.deltaX; let deltaY = e.deltaY; - // When the user is holding the shift key, invert the delta X and delta Y. + // When the user is holding the shift key, invert delta X and delta Y. if (e.shiftKey) { [deltaX, deltaY] = [deltaY, deltaX]; } - // The predominant axis is vertical scrolling. When delta Y is greater than or equal to - // delta X, ignore and reset the delta X and scroll vertically. - if (Math.abs(deltaY) >= Math.abs(deltaX)) { - // Calculate the adjusted wheel delta Y. - const adjustedWheelDeltaY = wheelDeltaY + (e.altKey ? deltaY * 10 : deltaY); - - // Reset wheel delta X. - setWheelDeltaX(0); - - // Determine whether there's enough delta Y to scroll one or more rows. - const rowsToScroll = Math.trunc(adjustedWheelDeltaY / MOUSE_WHEEL_SENSITIVITY); - if (!rowsToScroll) { - setWheelDeltaY(adjustedWheelDeltaY); - } else { - await context.instance.setFirstRow(pinToRange( - context.instance.firstRowIndex + rowsToScroll, - 0, - context.instance.maximumFirstRowIndex - )); - setWheelDeltaY(adjustedWheelDeltaY - (rowsToScroll * MOUSE_WHEEL_SENSITIVITY)); - } - } else if (Math.abs(deltaX) >= Math.abs(deltaY)) { - // Calculate the adjusted wheel delta X. - const adjustedWheelDeltaX = wheelDeltaX + (e.altKey ? deltaX * 10 : deltaX); - - // Determine whether there's enough delta X to scroll one or more columns. - const columnsToScroll = Math.trunc(adjustedWheelDeltaX / MOUSE_WHEEL_SENSITIVITY); - if (columnsToScroll) { - await context.instance.setFirstColumn(pinToRange( - context.instance.firstColumnIndex + columnsToScroll, - 0, - context.instance.maximumFirstColumnIndex - )); - setWheelDeltaX(adjustedWheelDeltaX - (columnsToScroll * MOUSE_WHEEL_SENSITIVITY)); - } else { - setWheelDeltaX(adjustedWheelDeltaX); - } - - // Reset wheel delta Y. - setWheelDeltaY(0); + // If the alt key is pressed, scroll by 10 times the delta X and delta Y. + if (e.altKey) { + deltaX *= 10; + deltaY *= 10; } + + /** + * Sets the scroll offsets. + */ + await context.instance.setScrollOffsets( + pinToRange( + context.instance.horizontalScrollOffset + deltaX, + 0, + context.instance.maximumHorizontalScrollOffset + ), + pinToRange( + context.instance.verticalScrollOffset + deltaY, + 0, + context.instance.maximumVerticalScrollOffset + ) + ); }; - // Render the data grid rows. + // Create the data grid rows. const dataGridRows: JSX.Element[] = []; - for (let rowIndex = context.instance.firstRowIndex, top = 0; - rowIndex < context.instance.rows && top < height; - rowIndex++ + for (let rowLayoutEntry = context.instance.firstRow; + rowLayoutEntry && rowLayoutEntry.top < context.instance.layoutBottom; + rowLayoutEntry = context.instance.getRow(rowLayoutEntry.rowIndex + 1) ) { - // Render the data grid row. dataGridRows.push( ); - - // Adjust the top for the next row. - top += context.instance.getRowHeight(rowIndex); } // Render. @@ -555,91 +518,84 @@ export const DataGridWaffle = forwardRef((_: unknown, ref) => { {context.instance.columnHeaders && context.instance.rowHeaders && { - await context.instance.setScreenPosition(0, 0); + await context.instance.setScrollOffsets(0, 0); }} /> } - {context.instance.columnHeaders && } - {context.instance.rowHeaders && } - {context.instance.horizontalScrollbar && - await context.instance.setFirstColumn(firstColumnIndex) + context.instance.horizontalScrollbar && context.instance.verticalScrollbar } + scrollbarThickness={context.instance.scrollbarThickness} + scrollSize={context.instance.scrollWidth} + layoutSize={context.instance.layoutWidth} + pageSize={context.instance.pageWidth} + scrollOffset={context.instance.horizontalScrollOffset} + maximumScrollOffset={() => context.instance.maximumHorizontalScrollOffset} + onDidChangeScrollOffset={async scrollOffset => { + await context.instance.setHorizontalScrollOffset(scrollOffset); + }} /> } - {context.instance.verticalScrollbar && - await context.instance.setFirstRow(firstRowIndex) + context.instance.horizontalScrollbar && context.instance.verticalScrollbar } + scrollbarThickness={context.instance.scrollbarThickness} + scrollSize={context.instance.scrollHeight} + layoutSize={context.instance.layoutHeight} + pageSize={context.instance.pageHeight} + scrollOffset={context.instance.verticalScrollOffset} + maximumScrollOffset={() => context.instance.maximumVerticalScrollOffset} + onDidChangeScrollOffset={async scrollOffset => { + await context.instance.setVerticalScrollOffset(scrollOffset); + }} /> } - {context.instance.horizontalScrollbar && context.instance.verticalScrollbar && { - await context.instance.setScreenPosition( - context.instance.maximumFirstColumnIndex, - context.instance.maximumFirstRowIndex + await context.instance.setScrollOffsets( + context.instance.maximumHorizontalScrollOffset, + context.instance.maximumVerticalScrollOffset ); }} /> } -
-
{dataGridRows}
-
); diff --git a/src/vs/workbench/services/positronDataExplorer/browser/components/columnSummaryCell.tsx b/src/vs/workbench/services/positronDataExplorer/browser/components/columnSummaryCell.tsx index 546708217f5..d503b3c46f9 100644 --- a/src/vs/workbench/services/positronDataExplorer/browser/components/columnSummaryCell.tsx +++ b/src/vs/workbench/services/positronDataExplorer/browser/components/columnSummaryCell.tsx @@ -383,8 +383,8 @@ export const ColumnSummaryCell = (props: ColumnSummaryCellProps) => { { 'disabled': !summaryStatsSupported } ) } - onClick={summaryStatsSupported ? () => - props.instance.toggleExpandColumn(props.columnIndex) : undefined + onClick={summaryStatsSupported ? async () => + await props.instance.toggleExpandColumn(props.columnIndex) : undefined } > {expanded ? diff --git a/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerInstance.ts b/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerInstance.ts index 0e7d685ac33..b92bab7d8f2 100644 --- a/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerInstance.ts +++ b/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerInstance.ts @@ -94,7 +94,7 @@ export class PositronDataExplorerInstance extends Disposable implements IPositro //#endregion Private Properties - //#region Constructor & Dispose + //#region Constructor /** * Constructor. @@ -124,26 +124,35 @@ export class PositronDataExplorerInstance extends Disposable implements IPositro // Call the base class's constructor. super(); - // Initialize. - this._tableSummaryCache = new TableSummaryCache( + // Take ownership of the client instance. + this._register(this._dataExplorerClientInstance); + + // Create the table summary cache. + this._register(this._tableSummaryCache = new TableSummaryCache( this._configurationService, this._dataExplorerClientInstance - ); - this._tableSchemaDataGridInstance = new TableSummaryDataGridInstance( + )); + + // Create the table summary data grid instance. + this._register(this._tableSchemaDataGridInstance = new TableSummaryDataGridInstance( this._configurationService, this._hoverService, this._dataExplorerClientInstance, this._tableSummaryCache - ); - this._tableDataCache = new TableDataCache(this._dataExplorerClientInstance); - this._tableDataDataGridInstance = new TableDataDataGridInstance( + )); + + // Create the table data cache. + this._register(this._tableDataCache = new TableDataCache(this._dataExplorerClientInstance)); + + // Create the table data data grid instance. + this._register(this._tableDataDataGridInstance = new TableDataDataGridInstance( this._commandService, this._configurationService, this._keybindingService, this._layoutService, this._dataExplorerClientInstance, this._tableDataCache - ); + )); // Add the onDidClose event handler. this._register(this._dataExplorerClientInstance.onDidClose(() => { @@ -156,24 +165,14 @@ export class PositronDataExplorerInstance extends Disposable implements IPositro this._tableDataDataGridInstance.scrollToColumn(columnIndex); })); + // Add the onDidRequestFocus event handler. this._register(this.onDidRequestFocus(() => { const uri = PositronDataExplorerUri.generate(this._dataExplorerClientInstance.identifier); this._editorService.openEditor({ resource: uri }); })); } - /** - * dispose override method. - */ - override dispose(): void { - // Dispose the client instance. - this._dataExplorerClientInstance.dispose(); - - // Call the base class's dispose method. - super.dispose(); - } - - //#endregion Constructor & Dispose + //#endregion Constructor //#region IPositronDataExplorerInstance Implementation diff --git a/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerService.ts b/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerService.ts index 56b166903b1..f1c7d7529f9 100644 --- a/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerService.ts +++ b/src/vs/workbench/services/positronDataExplorer/browser/positronDataExplorerService.ts @@ -434,18 +434,14 @@ class PositronDataExplorerService extends Disposable implements IPositronDataExp * @param dataExplorerClientInstance The DataExplorerClientInstance for the editor. */ private closeEditor(dataExplorerClientInstance: DataExplorerClientInstance) { - // Get the Positron data explorer client instance. + // Get the data explorer client instance. If it was found, delete it and dispose it. const positronDataExplorerInstance = this._positronDataExplorerInstances.get( dataExplorerClientInstance.identifier ); - - // If there isn't a Positron data explorer client instance, return. - if (!positronDataExplorerInstance) { - return; + if (positronDataExplorerInstance) { + this._positronDataExplorerInstances.delete(dataExplorerClientInstance.identifier); + positronDataExplorerInstance.dispose(); } - - // Delete the Positron data explorer client instance. - this._positronDataExplorerInstances.delete(dataExplorerClientInstance.identifier); } //#endregion Private Methods diff --git a/src/vs/workbench/services/positronDataExplorer/browser/tableDataDataGridInstance.tsx b/src/vs/workbench/services/positronDataExplorer/browser/tableDataDataGridInstance.tsx index 8cc7b3b0a10..ddd79b02ace 100644 --- a/src/vs/workbench/services/positronDataExplorer/browser/tableDataDataGridInstance.tsx +++ b/src/vs/workbench/services/positronDataExplorer/browser/tableDataDataGridInstance.tsx @@ -16,14 +16,13 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IColumnSortKey } from 'vs/workbench/browser/positronDataGrid/interfaces/columnSortKey'; import { TableDataCell } from 'vs/workbench/services/positronDataExplorer/browser/components/tableDataCell'; import { AnchorPoint } from 'vs/workbench/browser/positronComponents/positronModalPopup/positronModalPopup'; -import { SORTING_BUTTON_WIDTH } from 'vs/workbench/browser/positronDataGrid/components/dataGridColumnHeader'; import { TableDataRowHeader } from 'vs/workbench/services/positronDataExplorer/browser/components/tableDataRowHeader'; import { CustomContextMenuItem } from 'vs/workbench/browser/positronComponents/customContextMenu/customContextMenuItem'; -import { InvalidateCacheFlags, TableDataCache } from 'vs/workbench/services/positronDataExplorer/common/tableDataCache'; import { PositronDataExplorerColumn } from 'vs/workbench/services/positronDataExplorer/browser/positronDataExplorerColumn'; import { DataExplorerClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeDataExplorerClient'; import { CustomContextMenuSeparator } from 'vs/workbench/browser/positronComponents/customContextMenu/customContextMenuSeparator'; import { PositronDataExplorerCommandId } from 'vs/workbench/contrib/positronDataExplorerEditor/browser/positronDataExplorerActions'; +import { InvalidateCacheFlags, TableDataCache, WidthCalculators } from 'vs/workbench/services/positronDataExplorer/common/tableDataCache'; import { CustomContextMenuEntry, showCustomContextMenu } from 'vs/workbench/browser/positronComponents/customContextMenu/customContextMenu'; import { dataExplorerExperimentalFeatureEnabled } from 'vs/workbench/services/positronDataExplorer/common/positronDataExplorerExperimentalConfig'; import { BackendState, ColumnSchema, DataSelectionCellRange, DataSelectionIndices, DataSelectionRange, DataSelectionSingleCell, ExportFormat, RowFilter, SupportStatus, TableSelection, TableSelectionKind } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; @@ -40,11 +39,6 @@ const addFilterTitle = localize('positron.addFilter', "Add Filter"); export class TableDataDataGridInstance extends DataGridInstance { //#region Private Properties - /** - * Gets or sets the sort index width calculator. - */ - private _sortIndexWidthCalculator?: (sortIndex: number) => number; - /** * The onAddFilter event emitter. */ @@ -81,12 +75,13 @@ export class TableDataDataGridInstance extends DataGridInstance { defaultColumnWidth: 200, defaultRowHeight: 24, columnResize: true, - minimumColumnWidth: 20, - maximumColumnWidth: 400, + minimumColumnWidth: 80, + maximumColumnWidth: 800, rowResize: false, horizontalScrollbar: true, verticalScrollbar: true, - scrollbarWidth: 14, + scrollbarThickness: 14, + scrollbarOverscroll: 14, useEditorFont: true, automaticLayout: true, cellBorders: true, @@ -95,43 +90,85 @@ export class TableDataDataGridInstance extends DataGridInstance { cursorOffset: 0.5, }); + /** + * Updates the layout entries. + * @param state The backend state, if known; otherwise, undefined. + */ + const updateLayoutEntries = async (state?: BackendState) => { + // Get the backend state, if was not provided. + if (!state) { + state = await this._dataExplorerClientInstance.getBackendState(); + } + + // Calculate the layout entries. + const layoutEntries = await this._tableDataCache.calculateColumnLayoutEntries( + this.minimumColumnWidth, + this.maximumColumnWidth + ); + + // Set the layout entries. + this._columnLayoutManager.setLayoutEntries( + layoutEntries ?? state.table_shape.num_columns + ); + this._rowLayoutManager.setLayoutEntries( + state.table_shape.num_rows + ); + + // Adjust the vertical scroll offset, if needed. + if (!this.firstRow) { + this._verticalScrollOffset = 0; + } else if (this._verticalScrollOffset > this.maximumVerticalScrollOffset) { + this._verticalScrollOffset = this.maximumVerticalScrollOffset; + } + + // Adjust the horizontal scroll offset, if needed. + if (!this.firstColumn) { + this._horizontalScrollOffset = 0; + } else if (this._horizontalScrollOffset > this.maximumHorizontalScrollOffset) { + this._horizontalScrollOffset = this.maximumHorizontalScrollOffset; + } + }; + // Add the data explorer client onDidSchemaUpdate event handler. this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => { + // Update the layout entries. + await updateLayoutEntries(); + + // Perform a soft reset. + this.softReset(); + // Update the cache. - await this._tableDataCache.update({ - invalidateCache: InvalidateCacheFlags.All, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenColumns, - firstRowIndex: this.firstRowIndex, - screenRows: this.screenRows - }); + await this.fetchData(InvalidateCacheFlags.All); })); - // Add the the data explorer client onDidDataUpdate event handler. + // Add the data explorer client onDidDataUpdate event handler. this._register(this._dataExplorerClientInstance.onDidDataUpdate(async () => { + // Update the layout entries. + await updateLayoutEntries(); + // Update the cache. - await this._tableDataCache.update({ - invalidateCache: InvalidateCacheFlags.Data, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenColumns, - firstRowIndex: this.firstRowIndex, - screenRows: this.screenRows - }); + await this.fetchData(InvalidateCacheFlags.Data); })); // Add the data explorer client onDidUpdateBackendState event handler. - this._register(this._dataExplorerClientInstance.onDidUpdateBackendState( - (state: BackendState) => { - // Clear column sort keys. - this._columnSortKeys.clear(); - state.sort_keys.forEach((key, sortIndex) => { - this._columnSortKeys.set(key.column_index, - new ColumnSortKeyDescriptor(sortIndex, key.column_index, key.ascending) - ); - }); - this._onDidUpdateEmitter.fire(); - } - )); + this._register(this._dataExplorerClientInstance.onDidUpdateBackendState(async state => { + // Update the layout entries. + await updateLayoutEntries(state); + + // Clear column sort keys. + this._columnSortKeys.clear(); + + // Update the column sort keys from the state. + state.sort_keys.forEach((key, sortIndex) => { + this._columnSortKeys.set( + key.column_index, + new ColumnSortKeyDescriptor(sortIndex, key.column_index, key.ascending) + ); + }); + + // Fetch data. + await this.fetchData(InvalidateCacheFlags.Data); + })); // Add the table data cache onDidUpdate event handler. this._register(this._tableDataCache.onDidUpdate(() => @@ -158,51 +195,16 @@ export class TableDataDataGridInstance extends DataGridInstance { return this._tableDataCache.rows; } - //#endregion DataGridInstance Properties - - //#region DataGridInstance Methods - /** - * Gets a column width. - * @param columnIndex The column index. - * @returns The column width. + * Gets the page height. */ - override getColumnWidth(columnIndex: number): number { - // If we have a user-defined column width, return it. - const userDefinedColumnWidth = this._userDefinedColumnWidths.get(columnIndex); - if (userDefinedColumnWidth !== undefined) { - return Math.min(userDefinedColumnWidth, this.maximumColumnWidth); - } + override get pageHeight() { + return this.layoutHeight - this.defaultRowHeight; + } - // Get the column header width and the column value width. - let columnHeaderWidth = this._tableDataCache.getColumnHeaderWidth(columnIndex); - if (columnHeaderWidth !== undefined) { - const columnSortKeyDescriptor = this._columnSortKeys.get(columnIndex); - if (!columnSortKeyDescriptor) { - columnHeaderWidth += 2; - } else { - columnHeaderWidth += SORTING_BUTTON_WIDTH; - if (this._sortIndexWidthCalculator) { - columnHeaderWidth += this._sortIndexWidthCalculator( - columnSortKeyDescriptor.sortIndex + 80 - ) + 6; // +6 for left and right 3px margin. - } - } - } - const columnValueWidth = this._tableDataCache.getColumnValueWidth(columnIndex); - - // If we have a column header width and / or a column value width, return the column width. - if (columnHeaderWidth && columnValueWidth) { - return Math.min(Math.max(columnHeaderWidth, columnValueWidth), this.maximumColumnWidth); - } else if (columnHeaderWidth) { - return Math.min(columnHeaderWidth, this.maximumColumnWidth); - } else if (columnValueWidth) { - return Math.min(columnValueWidth, this.maximumColumnWidth); - } + //#endregion DataGridInstance Properties - // Return the default column width. - return this.defaultColumnWidth; - } + //#region DataGridInstance Methods /** * Sorts the data. @@ -210,36 +212,44 @@ export class TableDataDataGridInstance extends DataGridInstance { */ override async sortData(columnSorts: IColumnSortKey[]): Promise { // Set the sort columns. - await this._dataExplorerClientInstance.setSortColumns(columnSorts.map(columnSort => ( - { - column_index: columnSort.columnIndex, - ascending: columnSort.ascending - } - ))); - - // Update the cache. - await this._tableDataCache.update({ - invalidateCache: InvalidateCacheFlags.Data, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenColumns, - firstRowIndex: this.firstRowIndex, - screenRows: this.screenRows - }); + await this._dataExplorerClientInstance.setSortColumns(columnSorts.map(columnSort => ({ + column_index: columnSort.columnIndex, + ascending: columnSort.ascending + }))); + + // Get the first column layout entry and the first row layout entry. If they were found, + // update the cache. + const columnDescriptor = this.firstColumn; + const rowDescriptor = this.firstRow; + if (columnDescriptor && rowDescriptor) { + // Update the cache. + await this._tableDataCache.update({ + invalidateCache: InvalidateCacheFlags.Data, + firstColumnIndex: columnDescriptor.columnIndex, + screenColumns: this.screenColumns, + firstRowIndex: rowDescriptor.rowIndex, + screenRows: this.screenRows + }); + } } /** * Fetches data. + * @param invalidateCacheFlags The invalidate cache flags. * @returns A Promise that resolves when the operation is complete. */ - override async fetchData() { - // Update the cache. - await this._tableDataCache.update({ - invalidateCache: InvalidateCacheFlags.None, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenColumns, - firstRowIndex: this.firstRowIndex, - screenRows: this.screenRows - }); + override async fetchData(invalidateCacheFlags?: InvalidateCacheFlags) { + const columnDescriptor = this.firstColumn; + const rowDescriptor = this.firstRow; + if (columnDescriptor && rowDescriptor) { + await this._tableDataCache.update({ + invalidateCache: invalidateCacheFlags ?? InvalidateCacheFlags.None, + firstColumnIndex: columnDescriptor.columnIndex, + screenColumns: this.screenColumns, + firstRowIndex: rowDescriptor.rowIndex, + screenRows: this.screenRows + }); + } } /** @@ -560,27 +570,11 @@ export class TableDataDataGridInstance extends DataGridInstance { //#region Public Methods /** - * Sets the column header width calculator. - * @param calculator The column header width calculator. - */ - setColumnHeaderWidthCalculator(calculator?: (columnName: string, typeName: string) => number) { - this._tableDataCache.setColumnHeaderWidthCalculator(calculator); - } - - /** - * Sets the sort index width calculator. - * @param calculator The sort index width calculator. - */ - setSortIndexWidthCalculator(calculator?: (sortIndex: number) => number) { - this._sortIndexWidthCalculator = calculator; - } - - /** - * Sets the column value width calculator. - * @param calculator The column value width calculator. + * Sets the width calculators. + * @param widthCalculators The width calculators. */ - setColumnValueWidthCalculator(calculator?: (length: number) => number) { - this._tableDataCache.setColumnValueWidthCalculator(calculator); + setWidthCalculators(widthCalculators?: WidthCalculators) { + this._tableDataCache.setWidthCalculators(widthCalculators); } /** @@ -672,14 +666,20 @@ export class TableDataDataGridInstance extends DataGridInstance { // Synchronize the backend state. await this._dataExplorerClientInstance.updateBackendState(); - // Update the cache. - await this._tableDataCache.update({ - invalidateCache: InvalidateCacheFlags.Data, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenColumns, - firstRowIndex: this.firstRowIndex, - screenRows: this.screenRows - }); + // Get the first column layout entry and the first row layout entry. If they were found, + // update the cache. + const columnDescriptor = this.firstColumn; + const rowDescriptor = this.firstRow; + if (columnDescriptor && rowDescriptor) { + // Update the cache. + await this._tableDataCache.update({ + invalidateCache: InvalidateCacheFlags.Data, + firstColumnIndex: columnDescriptor.columnIndex, + screenColumns: this.screenColumns, + firstRowIndex: rowDescriptor.rowIndex, + screenRows: this.screenRows + }); + } } /** diff --git a/src/vs/workbench/services/positronDataExplorer/browser/tableSummaryDataGridInstance.tsx b/src/vs/workbench/services/positronDataExplorer/browser/tableSummaryDataGridInstance.tsx index 3c69c12dbcc..90d6ac50b93 100644 --- a/src/vs/workbench/services/positronDataExplorer/browser/tableSummaryDataGridInstance.tsx +++ b/src/vs/workbench/services/positronDataExplorer/browser/tableSummaryDataGridInstance.tsx @@ -64,13 +64,14 @@ export class TableSummaryDataGridInstance extends DataGridInstance { super({ columnHeaders: false, rowHeaders: false, - defaultColumnWidth: 200, + defaultColumnWidth: 0, defaultRowHeight: SUMMARY_HEIGHT, columnResize: false, rowResize: false, horizontalScrollbar: false, verticalScrollbar: true, - scrollbarWidth: 14, + scrollbarThickness: 14, + scrollbarOverscroll: 0, useEditorFont: false, automaticLayout: true, cellBorders: false, @@ -78,41 +79,78 @@ export class TableSummaryDataGridInstance extends DataGridInstance { selection: false }); - // Add the data explorer client instance onDidSchemaUpdate event handler. + // Set the column layout entries. There is always one column. + this._columnLayoutManager.setLayoutEntries(1); + + /** + * Updates the layout entries. + * @param state The backend state, if known; otherwise, undefined. + */ + const updateLayoutEntries = async (state?: BackendState) => { + // Get the backend state, if was not provided. + if (!state) { + state = await this._dataExplorerClientInstance.getBackendState(); + } + + // Set the layout entries. + this._rowLayoutManager.setLayoutEntries(state.table_shape.num_columns); + + // Adjust the vertical scroll offset, if needed. + if (!this.firstRow) { + this._verticalScrollOffset = 0; + } else if (this._verticalScrollOffset > this.maximumVerticalScrollOffset) { + this._verticalScrollOffset = this.maximumVerticalScrollOffset; + } + }; + + // Add the onDidSchemaUpdate event handler. this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => { - // Update the cache with invalidation. - await this._tableSummaryCache.update({ - invalidateCache: true, - firstColumnIndex: this.firstColumnIndex, - screenColumns: this.screenRows - }); + // Update the layout entries. + await updateLayoutEntries(); + + // Perform a soft reset. + this.softReset(); + + // Fetch data. + await this.fetchData(true); })); - // Add the data explorer client instance onDidDataUpdate event handler. + // Add the onDidDataUpdate event handler. this._register(this._dataExplorerClientInstance.onDidDataUpdate(async () => { + // Update the layout entries. + await updateLayoutEntries(); + // Refresh the column profiles because they rely on the data. await this._tableSummaryCache.refreshColumnProfiles(); + + // Fetch data. + await this.fetchData(true); })); - // Add the data explorer client instance onDidUpdateBackendState event handler. - this._register(this._dataExplorerClientInstance.onDidUpdateBackendState( - async (state: BackendState) => { - // Stringify the row filters. - const rowFilters = JSON.stringify(state.row_filters); - - // If the row filters have changed, refresh the column profiles. - if (this._lastRowFilters !== rowFilters) { - this._lastRowFilters = rowFilters; - await this._tableSummaryCache.refreshColumnProfiles(); - } - }) - ); + // Add the onDidUpdateBackendState event handler. + this._register(this._dataExplorerClientInstance.onDidUpdateBackendState(async state => { + // Update the layout entries. + await updateLayoutEntries(state); + + // Stringify the row filters. + const rowFilters = JSON.stringify(state.row_filters); + + // If the row filters have changed, refresh the column profiles because they rely on the + // data + if (this._lastRowFilters !== rowFilters) { + this._lastRowFilters = rowFilters; + await this._tableSummaryCache.refreshColumnProfiles(); + } + + // Fetch data. + await this.fetchData(true); + })); // Add the table summary cache onDidUpdate event handler. - this._register(this._tableSummaryCache.onDidUpdate(() => { + this._register(this._tableSummaryCache.onDidUpdate(() => // Fire the onDidUpdate event. - this._onDidUpdateEmitter.fire(); - })); + this._onDidUpdateEmitter.fire() + )); } //#endregion Constructor @@ -133,121 +171,51 @@ export class TableSummaryDataGridInstance extends DataGridInstance { return this._tableSummaryCache.columns; } + /** + * Gets the scroll width. + */ + override get scrollWidth() { + return 0; + } + + /** + * Gets the first column. + */ + override get firstColumn() { + return { + columnIndex: 0, + left: 0 + }; + } + //#endregion DataGridInstance Properties //#region DataGridInstance Methods /** * Fetches data. + * @param invalidateCache A value which indicates whether to invalidate the cache. * @returns A Promise that resolves when the operation is complete. */ - override async fetchData() { - await this._tableSummaryCache.update({ - invalidateCache: false, - firstColumnIndex: this.firstRowIndex, - screenColumns: this.screenRows - }); + override async fetchData(invalidateCache?: boolean) { + const rowDescriptor = this.firstRow; + if (rowDescriptor) { + await this._tableSummaryCache.update({ + invalidateCache: !!invalidateCache, + firstColumnIndex: rowDescriptor.rowIndex, + screenColumns: this.screenRows + }); + } } /** - * Gets the the width of a column. + * Gets the width of a column. * @param columnIndex The column index. */ override getColumnWidth(columnIndex: number): number { return this.layoutWidth; } - /** - * Gets the the height of a row. - * @param rowIndex The row index. - */ - override getRowHeight(rowIndex: number): number { - // If the column isn't expanded, return the summary height. - if (!this.isColumnExpanded(rowIndex)) { - return SUMMARY_HEIGHT; - } - - // Get the column schema. If it hasn't been loaded yet, return the summary height. - const columnSchema = this._tableSummaryCache.getColumnSchema(rowIndex); - if (!columnSchema) { - return SUMMARY_HEIGHT; - } - - /** - * Returns the row height with the specified number of lines. - * @param profileLines - * @returns - */ - const rowHeight = (displaySparkline: boolean, profileLines: number) => { - // Every row displays the column summary. - let rowHeight = SUMMARY_HEIGHT; - - // Account for the sparkline. - if (displaySparkline) { - rowHeight += 50 + 10; - } - - // Account for the profile lines. - if (profileLines) { - rowHeight += (profileLines * PROFILE_LINE_HEIGHT) + 12; - } - - // Return the row height. - return rowHeight; - }; - - // Return the row height. - switch (columnSchema.type_display) { - // Number. - case ColumnDisplayType.Number: { - return rowHeight( - !!this._tableSummaryCache.getColumnProfile(rowIndex)?.large_histogram, - COLUMN_PROFILE_NUMBER_LINE_COUNT - ); - } - - // Boolean. - case ColumnDisplayType.Boolean: { - return rowHeight( - !!this._tableSummaryCache.getColumnProfile(rowIndex)?.small_frequency_table, - COLUMN_PROFILE_BOOLEAN_LINE_COUNT - ); - } - - // String. - case ColumnDisplayType.String: { - return rowHeight( - !!this._tableSummaryCache.getColumnProfile(rowIndex)?.large_frequency_table, - COLUMN_PROFILE_STRING_LINE_COUNT - ); - } - - // Date. - case ColumnDisplayType.Date: { - return rowHeight(false, COLUMN_PROFILE_DATE_LINE_COUNT); - } - - // Datetime. - case ColumnDisplayType.Datetime: { - return rowHeight(false, COLUMN_PROFILE_DATE_TIME_LINE_COUNT); - } - - // Column display types that do not render a profile. - case ColumnDisplayType.Time: - case ColumnDisplayType.Object: - case ColumnDisplayType.Array: - case ColumnDisplayType.Struct: - case ColumnDisplayType.Unknown: { - return rowHeight(false, 0); - } - - // This shouldn't ever happen. - default: { - return rowHeight(false, 0); - } - } - } - /** * Gets a cell. * @param columnIndex The column index. @@ -324,6 +292,11 @@ export class TableSummaryDataGridInstance extends DataGridInstance { * @param columnIndex The columm index. */ async toggleExpandColumn(columnIndex: number) { + if (this._tableSummaryCache.isColumnExpanded(columnIndex)) { + this._rowLayoutManager.clearLayoutOverride(columnIndex); + } else { + this._rowLayoutManager.setLayoutOverride(columnIndex, this.expandedRowHeight(columnIndex)); + } return this._tableSummaryCache.toggleExpandColumn(columnIndex); } @@ -405,4 +378,85 @@ export class TableSummaryDataGridInstance extends DataGridInstance { } //#endregion Public Methods + + //#region Private Methods + + /** + * Gets an expanded row height. + * @param rowIndex The row index of the expanded row height to return. + * @returns The expanded row height. + */ + private expandedRowHeight(rowIndex: number): number { + // Get the column schema. If it hasn't been loaded yet, return the summary height. + const columnSchema = this._tableSummaryCache.getColumnSchema(rowIndex); + if (!columnSchema) { + return SUMMARY_HEIGHT; + } + + /** + * Calculates the row height. + * @param displaySparkline A value which indicates whether the sparkline will be displayed. + * @param profileLines The number of profile lines. + * @returns The row height. + */ + const rowHeight = (displaySparkline: boolean, profileLines: number) => { + // Every row displays the column summary. + let rowHeight = SUMMARY_HEIGHT; + + // Account for the sparkline. + if (displaySparkline) { + rowHeight += 50 + 10; + } + + // Account for the profile lines. + if (profileLines) { + rowHeight += (profileLines * PROFILE_LINE_HEIGHT) + 12; + } + + // Return the row height. + return rowHeight; + }; + + // Return the row height. + switch (columnSchema.type_display) { + // Number. + case ColumnDisplayType.Number: + return rowHeight(true, COLUMN_PROFILE_NUMBER_LINE_COUNT); + + // Boolean. + case ColumnDisplayType.Boolean: + return rowHeight(true, COLUMN_PROFILE_BOOLEAN_LINE_COUNT); + + // String. + case ColumnDisplayType.String: { + return rowHeight(true, COLUMN_PROFILE_STRING_LINE_COUNT); + } + + // Date. + case ColumnDisplayType.Date: { + return rowHeight(false, COLUMN_PROFILE_DATE_LINE_COUNT); + } + + // Datetime. + case ColumnDisplayType.Datetime: { + return rowHeight(false, COLUMN_PROFILE_DATE_TIME_LINE_COUNT); + } + + // Column display types that do not render a profile. + case ColumnDisplayType.Time: + case ColumnDisplayType.Object: + case ColumnDisplayType.Array: + case ColumnDisplayType.Struct: + case ColumnDisplayType.Unknown: { + return rowHeight(false, 0); + } + + // This shouldn't ever happen. + default: { + return rowHeight(false, 0); + } + } + } + + //#endregion Private Methods } diff --git a/src/vs/workbench/services/positronDataExplorer/common/columnSchemaCache.ts b/src/vs/workbench/services/positronDataExplorer/common/columnSchemaCache.ts index 43eb86a0ba8..3800d333eb9 100644 --- a/src/vs/workbench/services/positronDataExplorer/common/columnSchemaCache.ts +++ b/src/vs/workbench/services/positronDataExplorer/common/columnSchemaCache.ts @@ -80,10 +80,10 @@ export class ColumnSchemaCache extends Disposable { super(); // Add the onDidSchemaUpdate event handler. - this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => { + this._register(this._dataExplorerClientInstance.onDidSchemaUpdate(async () => // Clear the column schema cache. - this._columnSchemaCache.clear(); - })); + this._columnSchemaCache.clear() + )); } //#endregion Constructor & Dispose @@ -115,7 +115,7 @@ export class ColumnSchemaCache extends Disposable { * @param cacheUpdateDescriptor The cache update descriptor. * @returns A Promise that resolves when the update is complete. */ - async updateCache(cacheUpdateDescriptor: CacheUpdateDescriptor): Promise { + async update(cacheUpdateDescriptor: CacheUpdateDescriptor): Promise { // Update the cache. await this.doUpdateCache(cacheUpdateDescriptor); @@ -176,7 +176,9 @@ export class ColumnSchemaCache extends Disposable { firstColumnIndex - (visibleColumns * OVERSCAN_FACTOR), 0 ); - const endColumnIndex = startColumnIndex + visibleColumns + (visibleColumns * OVERSCAN_FACTOR * 2); + const endColumnIndex = startColumnIndex + + visibleColumns + + (visibleColumns * OVERSCAN_FACTOR * 2); // Build an array of the column indices to cache. const columnIndices = arrayFromIndexRange(startColumnIndex, endColumnIndex); diff --git a/src/vs/workbench/services/positronDataExplorer/common/layoutManager.ts b/src/vs/workbench/services/positronDataExplorer/common/layoutManager.ts new file mode 100644 index 00000000000..0db4348c84d --- /dev/null +++ b/src/vs/workbench/services/positronDataExplorer/common/layoutManager.ts @@ -0,0 +1,477 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * ILayoutEntry interface. + */ +export interface ILayoutEntry { + /** + * Gets index of the column or row. + */ + readonly index: number; + + /** + * Gets the X or Y coordinate of the column or row. + */ + readonly start: number; + + /** + * Gets the width or the height of the column or row. + */ + readonly size: number; + + /** + * Gets the end of the column or row. + */ + readonly end: number; +} + +/** + * LayoutEntry class. + */ +class LayoutEntry implements ILayoutEntry { + //#region Public Properties + + /** + * Gets index of the column or row. + */ + readonly index: number; + + /** + * Gets or sets the X or Y coordinate of the column or row. + */ + start: number; + + /** + * Gets or sets the default width or the height of the column or row. + */ + defaultSize: number; + + /** + * Gets or sets the override width or the height of the column or row. + */ + overrideSize?: number; + + /** + * Gets the size of the column or row. + */ + get size() { + return this.overrideSize ?? this.defaultSize; + } + + /** + * Gets the end of the column or row. + */ + get end() { + return this.start + this.size; + } + + //#endregion Public Properties + + //#region Constructor + + /** + * Constructor. + * @param index The index of the column or row. + * @param start The X or Y coordinate of the column or row. + * @param defaultSize The default width or the height of the column or row. + * @param overrideSize The override width or the height of the column or row. + */ + constructor(index: number, start: number, defaultSize: number, overrideSize?: number) { + this.index = index; + this.start = start; + this.defaultSize = defaultSize; + this.overrideSize = overrideSize; + } + + //#endregion Constructor +} + +/** + * ILayoutOverride interface. + */ +interface ILayoutOverride { + /** + * Gets index of the column or row. + */ + readonly index: number; + + /** + * Gets the override size. + */ + readonly overrideSize: number; +} + +/** + * LayoutManager class. + */ +export class LayoutManager { + //#region Private Properties + + /** + * Gets the default size. + */ + private readonly _defaultSize: number = 0; + + /** + * Gets or sets the layout entries. This is either a count of the layout entries or an array of + * the layout entries. + */ + private _layoutEntries: number | LayoutEntry[] = 0; + + /** + * Gets or sets the layout overrides. + */ + private _layoutOverrides = new Map(); + + /** + * Gets or sets the cached layout entry. + */ + private _cachedLayoutEntry?: LayoutEntry; + + //#endregion Private Properties + + //#region Constructor + + /** + * Constructor. + * @param defaultSize The default size. + */ + constructor(defaultSize: number = 0) { + this._defaultSize = defaultSize; + } + + //#endregion Constructor + + //#region Public Properties + + /** + * Gets the size of the layout entries. + */ + get size() { + // If the layout entries is an array, return the end of the last layout entry. + if (Array.isArray(this._layoutEntries)) { + return this._layoutEntries[this._layoutEntries.length - 1].end; + } + + // Calculate the size of the layout entries. + let size = this._layoutEntries * this._defaultSize; + const sortedLayoutOverrides = this.getSortedLayoutOverrides(); + for (let index = 0; index < sortedLayoutOverrides.length; index++) { + const layoutOverride = sortedLayoutOverrides[index]; + if (layoutOverride.index < this._layoutEntries) { + size = size - this._defaultSize + layoutOverride.overrideSize; + } else { + break; + } + } + + // Return the size of the layout entries. + return size; + } + + //#endregion Public Properties + + //#region Public Methods + + /** + * Sets the layout entries. + * @param layoutEntries The layout entries. + */ + setLayoutEntries(layoutEntries: number | number[]) { + // Clear the cached layout entry. + this._cachedLayoutEntry = undefined; + + // If layout entries is a number, set it; otherwise, create and populate the layout entries + // array from the supplied layout entries. + if (!Array.isArray(layoutEntries)) { + this._layoutEntries = layoutEntries; + return; + } + + // Create the layout entries array. + this._layoutEntries = new Array(layoutEntries.length); + + // Set the layout entries in the layout entries array. + for (let index = 0, start = 0; index < layoutEntries.length; index++) { + // Create the layout entry. + const layoutEntry = new LayoutEntry( + index, + start, + layoutEntries[index], + this._layoutOverrides.get(index) + ); + + // Set the layout entry. + this._layoutEntries[index] = layoutEntry; + + // Update the start for the next layout entry. + start = layoutEntry.end; + } + } + + /** + * Clears a layout override. + * @param index The index of the layout override. + */ + clearLayoutOverride(index: number) { + // Discard the cached layout entry, if it exists and its index is greater than the index of + // the layout override being cleared. + if (this._cachedLayoutEntry && this._cachedLayoutEntry.index >= index) { + this._cachedLayoutEntry = undefined; + } + + // Clear the layout override. + this._layoutOverrides.delete(index); + + // Adjust the layout entries. + if (Array.isArray(this._layoutEntries) && index < this._layoutEntries.length) { + // Get the layout entry for the layout override being cleared and clear its override + // size. + const layoutEntry = this._layoutEntries[index]; + layoutEntry.overrideSize = undefined; + + // Adjust the start of the remaining layout entries. + for (let i = index + 1, start = layoutEntry.end; i < this._layoutEntries.length; i++) { + // Update the start of the layout entry. + const layoutEntry = this._layoutEntries[i]; + layoutEntry.start = start; + + // Adjust the start for the next layout entry. + start = layoutEntry.end; + } + } + } + + /** + * Sets a layout override. + * @param index The index of the layout entry. + * @param overrideSize The override size of the layout entry. + */ + setLayoutOverride(index: number, overrideSize: number) { + // Sanity check the index and size. + if (!Number.isInteger(index) || index < 0 || overrideSize <= 0) { + return; + } + + // Discard the cached layout entry, if it exists and its index is greater than the index of + // the layout override. + if (this._cachedLayoutEntry && this._cachedLayoutEntry.index >= index) { + this._cachedLayoutEntry = undefined; + } + + // Set the layout override. + this._layoutOverrides.set(index, overrideSize); + + // Adjust the layout entries. + if (Array.isArray(this._layoutEntries) && index < this._layoutEntries.length) { + // Get the layout entry that was overridden and set its override size. + const layoutEntry = this._layoutEntries[index]; + layoutEntry.overrideSize = overrideSize; + + // Adjust the start of the remaining layout entries. + for (let i = index + 1, start = layoutEntry.end; i < this._layoutEntries.length; i++) { + // Update the start of the layout entry. + const layoutEntry = this._layoutEntries[i]; + layoutEntry.start = start; + + // Adjust the start for the next layout entry. + start = layoutEntry.end; + } + } + } + + /** + * Gets a layout entry by index + * @param index The index. + * @returns The layout entry at the specified index, if found; otherwise, undefined. + */ + getLayoutEntry(index: number): ILayoutEntry | undefined { + // Sanity check the index. + if (index < 0) { + return undefined; + } + + // If we have the layout entry cached, return it. + if (this._cachedLayoutEntry && this._cachedLayoutEntry.index === index) { + return this._cachedLayoutEntry; + } + + // If layout entries is an array, return the layout entry at the specified index. + if (Array.isArray(this._layoutEntries)) { + // Sanity check the index. + if (index >= this._layoutEntries.length) { + return undefined; + } + + // Return the layout entry. + return this._layoutEntries[index]; + } + + // Sanity check the index. + if (index >= this._layoutEntries) { + return undefined; + } + + // If there are no layout overrides, we can calculate which layout entry to return. + // Cache and return the layout entry. + if (!this._layoutOverrides.size) { + // Return the layout entry. + return new LayoutEntry( + index, + index * this._defaultSize, + this._defaultSize + ); + } + + // Calculate the start and size of the layout entry to return. + const sortedLayoutOverrides = this.getSortedLayoutOverrides(); + let start = index * this._defaultSize; + sortedLayoutOverrides.some(layoutOverride => { + // If the layout override index is less than the index, adjust the start and return + // false to continue the search. + if (layoutOverride.index < index) { + start = start - this._defaultSize + layoutOverride.overrideSize; + return false; + } + + // Return true to stop the search. + return true; + }); + + // Return the layout entry. + return new LayoutEntry( + index, + start, + this._defaultSize, + this._layoutOverrides.get(index) + ); + } + + /** + * Finds a layout entry. + * @param offset The offset of the layout entry to find. + * @returns The layout entry, if found; otherwise, undefined. + */ + findLayoutEntry(offset: number): ILayoutEntry | undefined { + // Sanity check the offset. + if (offset < 0) { + return undefined; + } + + // See if the layout entry is cached. If it is, return it. + if (this._cachedLayoutEntry) { + if (offset >= this._cachedLayoutEntry.start && offset < this._cachedLayoutEntry.end) { + return this._cachedLayoutEntry; + } + } + + // Find the layout entry to return. + if (!Array.isArray(this._layoutEntries)) { + // If there are no layout overrides, we can calculate which layout entry to return. + if (!this._layoutOverrides.size) { + // Calculate the layout entry index to return. If it's beyond the number of layout + // entries, return undefined. + const index = Math.floor(offset / this._defaultSize); + if (index >= this._layoutEntries) { + return undefined; + } + + // Cache and return the layout entry. + return this._cachedLayoutEntry = new LayoutEntry( + index, + index * this._defaultSize, + this._defaultSize + ); + } + + // Binary search the layout entries. + let leftIndex = 0; + let rightIndex = this._layoutEntries - 1; + const sortedLayoutOverrides = this.getSortedLayoutOverrides(); + while (leftIndex <= rightIndex) { + // Calculate the middle index. + const middleIndex = Math.floor((leftIndex + rightIndex) / 2); + + // Calculate the start and size of the middle layout entry. + let start = middleIndex * this._defaultSize; + sortedLayoutOverrides.some(layoutOverride => { + // If the layout override index is less than the middle index, adjust the start + // and return false to continue the search. + if (layoutOverride.index < middleIndex) { + start = start - this._defaultSize + layoutOverride.overrideSize; + return false; + } + + // Return true to stop the search. + return true; + }); + + // Check if the middle layout entry contains the offset. If so, cache and return it. + if (offset >= start && + offset < start + (this._layoutOverrides.get(middleIndex) ?? this._defaultSize) + ) { + // Cache and return the layout entry. + return this._cachedLayoutEntry = new LayoutEntry( + middleIndex, + start, + this._defaultSize, + this._layoutOverrides.get(middleIndex) + ); + } + + // Setup the next binary chop. + if (start < offset) { + leftIndex = middleIndex + 1; + } else { + rightIndex = middleIndex - 1; + } + } + + // Not found. + return undefined; + } else { + // Binary search the array of layout entries. + let leftIndex = 0; + let rightIndex = this._layoutEntries.length - 1; + while (leftIndex <= rightIndex) { + // Calculate the middle index and get the middle layout entry to check. + const middleIndex = Math.floor((leftIndex + rightIndex) / 2); + const middleLayoutEntry = this._layoutEntries[middleIndex]; + + // Check if the middle layout entry contains the offset. If so, cache and return it. + if (offset >= middleLayoutEntry.start && offset < middleLayoutEntry.end) { + // Cache the layout entry and return its layout. + return this._cachedLayoutEntry = middleLayoutEntry; + } + + // Setup the next binary chop. + if (middleLayoutEntry.start < offset) { + leftIndex = middleIndex + 1; + } else { + rightIndex = middleIndex - 1; + } + } + + // Not found. + return undefined; + } + } + + //#endregion Public Methods + + //#region Private Methods + + /** + * Gets the sorted layout overrides. + */ + private getSortedLayoutOverrides(): ILayoutOverride[] { + return Array.from(this._layoutOverrides). + map(([index, size]): ILayoutOverride => ({ index, overrideSize: size })). + sort((a, b) => a.index - b.index); + } + + //#endregion Private Methods +} diff --git a/src/vs/workbench/services/positronDataExplorer/common/tableDataCache.ts b/src/vs/workbench/services/positronDataExplorer/common/tableDataCache.ts index 0410d544bb4..075e58bf352 100644 --- a/src/vs/workbench/services/positronDataExplorer/common/tableDataCache.ts +++ b/src/vs/workbench/services/positronDataExplorer/common/tableDataCache.ts @@ -5,6 +5,7 @@ import { Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; +import { pinToRange } from 'vs/base/common/positronUtilities'; import { arrayFromIndexRange } from 'vs/workbench/services/positronDataExplorer/common/utils'; import { DataExplorerClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimeDataExplorerClient'; import { ArraySelection, ColumnSchema, ColumnSelection, DataSelectionIndices, DataSelectionRange } from 'vs/workbench/services/languageRuntime/common/positronDataExplorerComm'; @@ -12,9 +13,19 @@ import { ArraySelection, ColumnSchema, ColumnSelection, DataSelectionIndices, Da /** * Constants. */ -const TRIM_CACHE_TIMEOUT = 3000; +const MAX_AUTO_SIZE_COLUMNS = 1_000; +const AUTO_SIZE_COLUMNS_PAGE_SIZE = 250; +const TRIM_CACHE_TIMEOUT = 3_000; const OVERSCAN_FACTOR = 3; -const CHUNK_SIZE = 4096; +const CHUNK_SIZE = 4_096; + +/** + * WidthCalculators interface. + */ +export interface WidthCalculators { + columnHeaderWidthCalculator: (columnName: string, typeName: string) => number; + columnValueWidthCalculator: (length: number) => number; +} /** * InvalidateCacheFlags enum. @@ -138,25 +149,15 @@ export class TableDataCache extends Disposable { private _rows = 0; /** - * Gets or sets the column header width calculator. + * Gets or sets the width calculators. */ - private _columnHeaderWidthCalculator?: (columnName: string, typeName: string) => number; - - /** - * Gets or sets the column value width calculator. - */ - private _columnValueWidthCalculator?: (length: number) => number; + private _widthCalculators?: WidthCalculators; /** * Gets the column schema cache. */ private readonly _columnSchemaCache = new Map(); - /** - * Gets the column header width cache. - */ - private readonly _columnHeaderWidthCache = new Map(); - /** * Gets the row label cache. */ @@ -167,11 +168,6 @@ export class TableDataCache extends Disposable { */ private readonly _dataColumnCache = new Map>(); - /** - * Gets the column value width cache. - */ - private readonly _columnValueWidthCache = new Map(); - /** * The onDidUpdate event emitter. */ @@ -233,64 +229,112 @@ export class TableDataCache extends Disposable { //#region Public Methods /** - * Sets the column header width calculator. - * @param calculator The column header width calculator. + * Sets the width calculators. + * @param widthCalculators The width calculators. */ - setColumnHeaderWidthCalculator(calculator?: (columnName: string, typeName: string) => number) { - // Set the column header width calculator. - this._columnHeaderWidthCalculator = calculator; - - // Refresh the column header width cache, if the column header width calculator is non-null. - if (this._columnHeaderWidthCalculator) { - // Clear the existing column header width cache. - this._columnHeaderWidthCache.clear(); - - // Refresh the column header width cache. - for (const [columnIndex, columnSchema] of this._columnSchemaCache.entries()) { - // Calculate the column header width. - const columnHeaderWidth = this._columnHeaderWidthCalculator( - columnSchema.column_name, - columnSchema.type_name - ); - - // If the column header width is non-zero, cache its width. - if (columnHeaderWidth) { - this._columnHeaderWidthCache.set(columnIndex, columnHeaderWidth); - } - } - } + setWidthCalculators(widthCalculators?: WidthCalculators) { + this._widthCalculators = widthCalculators; } /** - * Sets the column value width calculator. - * @param calculator The column value width calculator. + * Calculates the column layout entries. + * @param minimumColumnWidth The minimum column width. + * @param maximumColumnWidth The maximum column width. + * @returns An array of column layout layout entries, if successful; otherwise, undefined. */ - setColumnValueWidthCalculator(calculator?: (length: number) => number) { - // Set the column value width calculator. - this._columnValueWidthCalculator = calculator; - - // Refresh the column value width cache, if the column value width calculator is non-null. - if (this._columnValueWidthCalculator) { - // Clear the existing column value width cache. - this._columnValueWidthCache.clear(); - - // Refresh the column value width cache. - for (const [columnIndex, dataColumn] of this._dataColumnCache.entries()) { - // Find the longest data cell. - let longestDataCell = 0; - for (const dataCell of dataColumn.values()) { - if (dataCell.formatted.length > longestDataCell) { - longestDataCell = dataCell.formatted.length; - } - } + async calculateColumnLayoutEntries( + minimumColumnWidth: number, + maximumColumnWidth: number + ): Promise { + // If the width calculators are not set, return undefined. + if (!this._widthCalculators) { + return undefined; + } + + // Get the number of columns. If there are no columns, or too many columns, return + // undefined. + const tableState = await this._dataExplorerClientInstance.getBackendState(); + const { num_columns: numColumns } = tableState.table_shape; + if (!numColumns || numColumns > MAX_AUTO_SIZE_COLUMNS) { + return undefined; + } - // If the longest data cell is non-zero, calculate and cache its width. - this._columnValueWidthCache.set( - columnIndex, - this._columnValueWidthCalculator(longestDataCell) + // Page in schema and data for column width calculations. + let columnIndex = 0; + const columnWidths: number[] = []; + while (columnIndex < numColumns) { + // Calculate the page size of the page of schema and the page of data to load. + const pageSize = Math.min(AUTO_SIZE_COLUMNS_PAGE_SIZE, numColumns - columnIndex); + + // Load the page of schema. + const tableSchema = await this._dataExplorerClientInstance.getSchema( + Array.from({ length: pageSize }, (_, i) => columnIndex + i) + ); + + // Calculate the column header widths for the page of schema that was returned. + for (let i = 0; i < tableSchema.columns.length; i++) { + const columnSchema = tableSchema.columns[i]; + columnWidths[columnIndex + i] = pinToRange( + this._widthCalculators.columnHeaderWidthCalculator( + columnSchema.column_name, + columnSchema.type_name + ), + minimumColumnWidth, + maximumColumnWidth ); } + + // Load the page of data. + const tableData = await this._dataExplorerClientInstance.getDataValues( + Array.from({ length: pageSize }, (_, i): ColumnSelection => ({ + column_index: columnIndex + i, + spec: { + first_index: 0, + last_index: 9 + } + })) + ); + + // Calculate the column value widths for the page of data that was returned. + for (let column = 0; column < tableData.columns.length; column++) { + for (let row = 0; row < tableData.columns[column].length; row++) { + // Get the cell value. + const value = tableData.columns[column][row]; + + // Convert the cell value into a data cell. + let dataCell: DataCell; + if (typeof value === 'number') { + dataCell = decodeSpecialValue(value); + } else { + dataCell = { + formatted: value, + kind: DataCellKind.NON_NULL + }; + } + + // Calculate the column value width. + const columnValueWidth = pinToRange( + this._widthCalculators.columnValueWidthCalculator( + dataCell.formatted.length + ), + minimumColumnWidth, + maximumColumnWidth + ); + + // If the column value width is larger than the column header width, set it as + // the column width. + if (columnValueWidth > columnWidths[columnIndex + column]) { + columnWidths[columnIndex + column] = columnValueWidth; + } + } + } + + // Adjust the column index for the next page to load. + columnIndex += pageSize; } + + // Return the column widths. + return columnWidths; } /** @@ -363,7 +407,6 @@ export class TableDataCache extends Disposable { // Clear the column schema cache, if we're supposed to. if (invalidateColumnSchemaCache) { this._columnSchemaCache.clear(); - this._columnHeaderWidthCache.clear(); } // Cache the column schemas that were returned. @@ -374,14 +417,6 @@ export class TableDataCache extends Disposable { // Cache the column schema. this._columnSchemaCache.set(columnIndex, columnSchema); - - // Update the column header width cache. - if (this._columnHeaderWidthCalculator) { - this._columnHeaderWidthCache.set(columnIndex, this._columnHeaderWidthCalculator( - columnSchema.column_name, - columnSchema.type_name - )); - } } // Fire the onDidUpdate event. @@ -399,11 +434,11 @@ export class TableDataCache extends Disposable { ); // Build an array of the column selections to load. - const columns: ColumnSelection[] = []; + const columnSelections: ColumnSelection[] = []; if (invalidateDataCache) { // The data cache is being invalidated. Load everything. for (let columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++) { - columns.push({ + columnSelections.push({ column_index: columnIndex, spec: { first_index: startRowIndex, @@ -417,7 +452,7 @@ export class TableDataCache extends Disposable { const dataColumn = this._dataColumnCache.get(columnIndex); if (!dataColumn) { // The data column isn't cached. Load it. - columns.push({ + columnSelections.push({ column_index: columnIndex, spec: { first_index: startRowIndex, @@ -446,14 +481,14 @@ export class TableDataCache extends Disposable { // If there are cells that are not cached, add the column and its spec. if (indices.length) { if (!contiguous) { - columns.push({ + columnSelections.push({ column_index: columnIndex, spec: { indices: indices } }); } else { - columns.push({ + columnSelections.push({ column_index: columnIndex, spec: { first_index: indices[0], @@ -467,7 +502,7 @@ export class TableDataCache extends Disposable { } // Get the data values. - const tableData = await this._dataExplorerClientInstance.getDataValues(columns); + const tableData = await this._dataExplorerClientInstance.getDataValues(columnSelections); // Get the row labels. let rowLabels: ArraySelection | undefined; @@ -519,13 +554,12 @@ export class TableDataCache extends Disposable { if (invalidateDataCache) { this._rowLabelCache.clear(); this._dataColumnCache.clear(); - this._columnValueWidthCache.clear(); } // Update the data column cache. for (let column = 0; column < tableData.columns.length; column++) { // Get the column selection. - const columnSelection = columns[column]; + const columnSelection = columnSelections[column]; // Get or create the data column. let dataColumn = this._dataColumnCache.get(columnSelection.column_index); @@ -562,22 +596,6 @@ export class TableDataCache extends Disposable { // Cache the cell. dataColumn.set(rowIndex, dataCell); - - // Update the column value width cache. - if (dataCell.formatted.length && this._columnValueWidthCalculator) { - // Get the cached column value width and the column value width. - const cachedColumnValueWidth = this._columnValueWidthCache.get( - columnSelection.column_index - ); - const columnValueWidth = this._columnValueWidthCalculator( - dataCell.formatted.length - ); - - // Update the column value width cache as needed. - if (!cachedColumnValueWidth || columnValueWidth > cachedColumnValueWidth) { - this._columnValueWidthCache.set(columnSelection.column_index, columnValueWidth); - } - } } } @@ -649,24 +667,6 @@ export class TableDataCache extends Disposable { return this._columnSchemaCache.get(columnIndex); } - /** - * Gets the column header width for the specified column index. - * @param columnIndex The column index. - * @returns The column header width for the specified column index - */ - getColumnHeaderWidth(columnIndex: number) { - return this._columnHeaderWidthCache.get(columnIndex); - } - - /** - * Gets the column value width for the specified column index. - * @param columnIndex The column index. - * @returns The column value width for the specified column index - */ - getColumnValueWidth(columnIndex: number) { - return this._columnValueWidthCache.get(columnIndex); - } - /** * Gets the row label for the specified row index. * @param rowIndex The row index. diff --git a/src/vs/workbench/test/common/layoutManager.test.ts b/src/vs/workbench/test/common/layoutManager.test.ts new file mode 100644 index 00000000000..96040ff7064 --- /dev/null +++ b/src/vs/workbench/test/common/layoutManager.test.ts @@ -0,0 +1,687 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { LayoutManager } from 'vs/workbench/services/positronDataExplorer/common/layoutManager'; + +/** + * Tests the LayoutManager class. + */ +suite('LayoutManager', () => { + /** + * Tests size. + */ + test('Size', () => { + verifySizeOfDefaultSizedEntries(1, 1); + verifySizeOfDefaultSizedEntries(123, 100); + verifySizeOfDefaultSizedEntries(4096, 5_000_000); + + verifySizeOfFixedSizedEntries(1, 1); + verifySizeOfFixedSizedEntries(123, 100); + verifySizeOfFixedSizedEntries(167, 20_000); + + verifySizeOfRandomlySizedEntries(1); + verifySizeOfRandomlySizedEntries(100); + verifySizeOfRandomlySizedEntries(20_000); + }); + + /** + * Tests getting a layout entry. + */ + test('Get Layout Entry', () => { + verifyGetLayoutEntryOfDefaultSizedEntries(1, 1); + verifyGetLayoutEntryOfDefaultSizedEntries(123, 100); + verifyGetLayoutEntryOfDefaultSizedEntries(4096, 5_000_000); + + verifyGetLayoutEntryOfFixedSizedEntries(1, 1); + verifyGetLayoutEntryOfFixedSizedEntries(123, 100); + verifyGetLayoutEntryOfFixedSizedEntries(167, 20_000); + + verifyGetLayoutEntryOfRandomlySizedEntries(1); + verifyGetLayoutEntryOfRandomlySizedEntries(100); + verifyGetLayoutEntryOfRandomlySizedEntries(20_000); + }); + + /** + * Tests default-sized entries. + */ + test('Default-Sized Entries', () => { + verifyDefaultSizedEntries(1, 2); + verifyDefaultSizedEntries(10, 10); + verifyDefaultSizedEntries(1, 1_000); + verifyDefaultSizedEntries(19, 1_000); + verifyDefaultSizedEntries(127, 20_000); + verifyDefaultSizedEntries(23, 500_000); + }); + + /** + * Tests default-sized entries with overrides. + */ + test('Default-Sized Entries With Overrides', () => { + verifyDefaultSizedEntriesWithOverrides(127, 253, 20_000, 500); + verifyDefaultSizedEntriesWithOverrides(200, 18, 50_000, 1_000); + verifyDefaultSizedEntriesWithOverrides(187, 392, 50_000_000, 10_000); + }); + + /** + * Tests fixed-sized predefined entries. + */ + test('Fixed-Sized Predefined Entries', () => { + verifyFixedSizedPredefinedEntries(1, 1); + verifyFixedSizedPredefinedEntries(10, 10); + verifyFixedSizedPredefinedEntries(1, 1_000); + verifyFixedSizedPredefinedEntries(19, 1_000); + verifyFixedSizedPredefinedEntries(127, 20_000); + verifyFixedSizedPredefinedEntries(23, 500_000); + }); + + /** + * Tests randomly-sized predefined entries. + */ + test('Randomly-Sized Predefined Entries', () => { + verifyRandomlySizedPredefinedEntries(1); + verifyRandomlySizedPredefinedEntries(10); + verifyRandomlySizedPredefinedEntries(1_000); + verifyRandomlySizedPredefinedEntries(20_000); + }); + + /** + * Verify size for default-sized entries. + */ + const verifySizeOfDefaultSizedEntries = (defaultSize: number, entries: number) => { + // Create and initialize the layout manager. + const layoutManager = new LayoutManager(defaultSize); + layoutManager.setLayoutEntries(entries); + const size = defaultSize * entries; + + // Verify size. + assert.strictEqual(layoutManager.size, size); + + // Add a layout override that will affect the size, and one that will not, for coverage. + layoutManager.setLayoutOverride(0, defaultSize * 2); + layoutManager.setLayoutOverride(entries, defaultSize * 2); + + // Verify size. + assert.strictEqual(layoutManager.size, size + defaultSize); + + // Get a layout entry cached, for coverage. + layoutManager.findLayoutEntry(0); + + // Clear layout overrides. + layoutManager.clearLayoutOverride(0); + layoutManager.clearLayoutOverride(entries); + + // Verify size. + assert.strictEqual(layoutManager.size, size); + }; + + /** + * Verify size for fixed-sized entries. + */ + const verifySizeOfFixedSizedEntries = (entrySize: number, entries: number) => { + // Create and initialize the layout manager. + const layoutManager = new LayoutManager(0); + layoutManager.setLayoutEntries(Array.from({ length: entries }, (_, i) => entrySize)); + const size = entrySize * entries; + + // Verify size. + assert.strictEqual(layoutManager.size, size); + + // Add a layout override that will affect the size, and one that will not, for coverage. + layoutManager.setLayoutOverride(0, entrySize * 2); + layoutManager.setLayoutOverride(entries, entrySize * 2); + + // Verify size. + assert.strictEqual(layoutManager.size, size + entrySize); + + // Get a layout entry cached, for coverage. + layoutManager.findLayoutEntry(0); + + // Clear layout overrides. + layoutManager.clearLayoutOverride(0); + layoutManager.clearLayoutOverride(entries); + + // Verify size. + assert.strictEqual(layoutManager.size, size); + }; + + /** + * Verify size for randomly-sized entries. + */ + const verifySizeOfRandomlySizedEntries = (entries: number) => { + // Create the layout manager. + const layoutManager = new LayoutManager(0); + const layoutEntries = Array.from({ length: entries }, (_, i) => + getRandomIntInclusive(1, 4096) + ); + layoutManager.setLayoutEntries(layoutEntries); + const size = layoutEntries.reduce((size, randomSize) => size + randomSize, 0); + + // Verify size. + assert.strictEqual(layoutManager.size, size); + + // Add a layout override that will affect the size, and one that will not, for coverage. + layoutManager.setLayoutOverride(0, layoutEntries[0] * 2); + layoutManager.setLayoutOverride(entries, 354); + + // Verify size. + assert.strictEqual(layoutManager.size, size + layoutEntries[0]); + + // Get a layout entry cached, for coverage. + layoutManager.findLayoutEntry(0); + + // Clear layout overrides. + layoutManager.clearLayoutOverride(0); + layoutManager.clearLayoutOverride(entries); + + // Verify size. + assert.strictEqual(layoutManager.size, size); + }; + + /** + * Verify getting a layout entry of default-sized entries. + * @param defaultSize + * @param entries + */ + const verifyGetLayoutEntryOfDefaultSizedEntries = (defaultSize: number, entries: number) => { + // Create and initialize the layout manager. + const layoutManager = new LayoutManager(defaultSize); + layoutManager.setLayoutEntries(entries); + + // Verify getting the first layout entry. + let layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, defaultSize); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, (entries - 1) * defaultSize); + assert.strictEqual(layoutEntry.size, defaultSize); + + // Add a layout override. + layoutManager.setLayoutOverride(0, defaultSize * 2); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, defaultSize * 2); + + // Verify getting the last layout entry. + if (entries > 1) { + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, ((entries - 1) * defaultSize) + defaultSize); + assert.strictEqual(layoutEntry.size, defaultSize); + } + + // Clear the layout override. + layoutManager.clearLayoutOverride(0); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, defaultSize); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, (entries - 1) * defaultSize); + assert.strictEqual(layoutEntry.size, defaultSize); + }; + + /** + * Verify getting a layout entry of fixed-sized entries. + * @param entrySize The entry size. + * @param entries The number of entries. + */ + const verifyGetLayoutEntryOfFixedSizedEntries = (entrySize: number, entries: number) => { + // Create and initialize the layout manager. + const layoutManager = new LayoutManager(0); + layoutManager.setLayoutEntries(Array.from({ length: entries }, (_, i) => entrySize)); + + // Verify getting the first layout entry. + let layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, entrySize); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, (entries - 1) * entrySize); + assert.strictEqual(layoutEntry.size, entrySize); + + // Add a layout override. + layoutManager.setLayoutOverride(0, entrySize * 2); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, entrySize * 2); + + // Verify getting the last layout entry. + if (entries > 1) { + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, ((entries - 1) * entrySize) + entrySize); + assert.strictEqual(layoutEntry.size, entrySize); + } + + // Clear the layout override. + layoutManager.clearLayoutOverride(0); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, entrySize); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, (entries - 1) * entrySize); + assert.strictEqual(layoutEntry.size, entrySize); + }; + + /** + * Verify getting a layout entry of randomly-sized entries. + * @param defaultSize + * @param entries + */ + const verifyGetLayoutEntryOfRandomlySizedEntries = (entries: number) => { + // Create the layout manager. + const layoutManager = new LayoutManager(0); + const layoutEntries = Array.from({ length: entries }, (_, i) => + getRandomIntInclusive(1, 4096) + ); + layoutManager.setLayoutEntries(layoutEntries); + const size = layoutEntries.reduce((size, randomSize) => size + randomSize, 0); + + // Verify getting the first layout entry. + let layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, layoutEntries[0]); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, size - layoutEntries[entries - 1]); + assert.strictEqual(layoutEntry.size, layoutEntries[entries - 1]); + + // Add a layout override. + layoutManager.setLayoutOverride(0, layoutEntries[0] * 2); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, layoutEntries[0] * 2); + + // Verify getting the last layout entry. + if (entries > 1) { + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, size + layoutEntries[0] - layoutEntries[entries - 1]); + assert.strictEqual(layoutEntry.size, layoutEntries[entries - 1]); + } + + // Add a layout override. + layoutManager.clearLayoutOverride(0); + + // Verify getting the first layout entry. + layoutEntry = layoutManager.getLayoutEntry(0); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, 0); + assert.strictEqual(layoutEntry.start, 0); + assert.strictEqual(layoutEntry.size, layoutEntries[0]); + + // Verify getting the last layout entry. + layoutEntry = layoutManager.getLayoutEntry(entries - 1); + assert(layoutEntry); + assert.strictEqual(layoutEntry.index, entries - 1); + assert.strictEqual(layoutEntry.start, size - layoutEntries[entries - 1]); + assert.strictEqual(layoutEntry.size, layoutEntries[entries - 1]); + }; + + /** + * Verify default-sized entries. + * @param defaultSize The default size of each entry. + * @param entries The number of entries. + */ + const verifyDefaultSizedEntries = (defaultSize: number, entries: number) => { + // Create the layout manager. + const layoutManager = new LayoutManager(defaultSize); + layoutManager.setLayoutEntries(entries); + + // Verify that every entry is correct. + for (let entry = 0; entry < entries; entry++) { + // Verify that every offset for every entry is correct. + for (let offset = 0; offset < defaultSize; offset++) { + const start = defaultSize * entry; + const layoutEntry = layoutManager.findLayoutEntry(start + offset); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, entry); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.end, start + defaultSize); + } + } + + // Verify entries that should not be found. + verifyEntriesThatShouldNotBeFound(layoutManager, entries, defaultSize); + }; + + /** + * Verify default-sized entries with overrides. + * @param defaultSize The default size of each entry. + * @param overrideSize The override size. + * @param entries The number of entries. + * @param overrideEntries The number of override entries. + */ + const verifyDefaultSizedEntriesWithOverrides = ( + defaultSize: number, + overrideSize: number, + entries: number, + overrideEntries: number + ) => { + // Define parameters. + const overridesStartAt = Math.floor(entries / 2); + + // Create the layout manager. + const layoutManager = new LayoutManager(defaultSize); + layoutManager.setLayoutEntries(entries); + + // Add bogus layout overrides. + layoutManager.setLayoutOverride(100.1, 1); + layoutManager.setLayoutOverride(1, 100.1); + layoutManager.setLayoutOverride(-1, 1); + layoutManager.setLayoutOverride(1, -1); + + // Add the layout overrides in reverse order for better coverage. + for (let i = overrideEntries - 1; i >= 0; i--) { + layoutManager.setLayoutOverride(overridesStartAt + i, overrideSize); + } + + // Add a layout override beyond the end for coverage. + layoutManager.setLayoutOverride(entries, overrideSize); + + // Add and remove a layout override. + layoutManager.setLayoutOverride(1, 1); + layoutManager.clearLayoutOverride(1); + + /** + * Verifies a layout entry before the overrides. + * @param index The index of the layout entry to verify. + */ + const verifyLayoutEntryBeforeOverrides = (index: number) => { + // Assert that the index is within the range. + assert(index < overridesStartAt); + + // Verify the layout entry. + let layoutEntry = layoutManager.findLayoutEntry(defaultSize * index); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, defaultSize * index); + assert.strictEqual(layoutEntry!.size, defaultSize); + + // Verify the layout entry. + layoutEntry = layoutManager.findLayoutEntry( + (defaultSize * index) + Math.floor(defaultSize / 2) + ); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, defaultSize * index); + assert.strictEqual(layoutEntry!.size, defaultSize); + }; + + // Verify a subset of the layout entries before the overrides. + verifyLayoutEntryBeforeOverrides(0); + for (let i = 0; i < 100; i++) { + verifyLayoutEntryBeforeOverrides(getRandomIntInclusive(1, overridesStartAt - 2)); + } + verifyLayoutEntryBeforeOverrides(overridesStartAt - 1); + + /** + * Verifies a layout entry in the overrides. + * @param testIndex The test index. + */ + let startingOffset = defaultSize * overridesStartAt; + const verifyLayoutEntryInOverrides = (testIndex: number) => { + // Calculate the index. + const index = testIndex + overridesStartAt; + + // Assert that the index is within the range. + assert(index >= overridesStartAt); + assert(index < overridesStartAt + overrideEntries); + + // Verify the layout entry. + const start = startingOffset + (testIndex * overrideSize); + let layoutEntry = layoutManager.findLayoutEntry(start); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.size, overrideSize); + + // Verify the layout entry. + layoutEntry = layoutManager.findLayoutEntry(start + Math.floor(overrideSize / 2)); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.size, overrideSize); + }; + + // Verify a subset of the layout entries in the overrides. + verifyLayoutEntryInOverrides(0); + for (let i = 0; i < 100; i++) { + verifyLayoutEntryInOverrides(getRandomIntInclusive(1, overrideEntries - 2)); + } + verifyLayoutEntryInOverrides(overrideEntries - 1); + + /** + * Verifies a layout entry after the overrides. + * @param index The index of the layout entry to verify. + */ + startingOffset += overrideEntries * overrideSize; + const verifyLayoutEntryAfterOverrides = (testIndex: number) => { + // Calculate the index. + const index = testIndex + overridesStartAt + overrideEntries; + + // Assert that the index is within the range. + assert(index >= overridesStartAt + overrideEntries); + assert(index < entries); + + // Verify the layout entry. + const start = startingOffset + (defaultSize * testIndex); + let layoutEntry = layoutManager.findLayoutEntry(start); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.size, defaultSize); + + // Verify the layout entry. + layoutEntry = layoutManager.findLayoutEntry(start + Math.floor(defaultSize / 2)); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, index); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.size, defaultSize); + }; + + // Verify a random subset of the layout entries after the overrides. + verifyLayoutEntryAfterOverrides(0); + for (let i = 0; i < 100; i++) { + verifyLayoutEntryAfterOverrides( + getRandomIntInclusive(1, entries - overridesStartAt - overrideEntries - 2) + ); + } + verifyLayoutEntryAfterOverrides(entries - overridesStartAt - overrideEntries - 1); + + // Verify finding a layout entry that should not be found. + assert(!layoutManager.findLayoutEntry(Number.MAX_SAFE_INTEGER)); + assert(!layoutManager.findLayoutEntry(Number.MIN_SAFE_INTEGER)); + + // Verify getting the layout entry past the last index, for coverage. + assert(!layoutManager.getLayoutEntry(entries)); + }; + + /** + * Verify fixed-sized predefined entries. + * @param entrySize The size of each entry. + * @param entries The number of entries. + */ + const verifyFixedSizedPredefinedEntries = (entrySize: number, entries: number) => { + // Create the layout manager. + const layoutManager = new LayoutManager(0); + layoutManager.setLayoutEntries(Array.from({ length: entries }, (_, i) => entrySize)); + + // Verify that every entry is correct. + for (let entry = 0; entry < entries; entry++) { + // Verify that every offset for every entry is correct. + for (let offset = 0; offset < entrySize; offset++) { + const start = entry * entrySize; + const layoutEntry = layoutManager.findLayoutEntry(start + offset); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, entry); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.end, start + entrySize); + } + } + + // Verify getting various layout entries. + assert(!layoutManager.getLayoutEntry(-1)); + assert(!layoutManager.getLayoutEntry(entries)); + assert.deepEqual( + layoutManager.getLayoutEntry(0), + { + index: 0, + start: 0, + defaultSize: entrySize, + overrideSize: undefined + } + ); + assert.deepEqual( + layoutManager.getLayoutEntry(entries - 1), + { + index: entries - 1, + start: (entries - 1) * entrySize, + defaultSize: entrySize, + overrideSize: undefined + } + ); + + // Override the first entry. + const layoutOverride = Math.ceil(entrySize / 2); + layoutManager.setLayoutOverride(0, layoutOverride); + + // Verify entries that should not be found. + verifyEntriesThatShouldNotBeFound(layoutManager, entries, entrySize); + + // Verify the size. + assert.strictEqual(layoutManager.size, (entrySize * (entries - 1)) + layoutOverride); + }; + + /** + * Verify randomly-sized predefined entries. + * @param entries The number of entries. + */ + const verifyRandomlySizedPredefinedEntries = (entries: number) => { + // Create the layout manager. + const layoutManager = new LayoutManager(0); + const layoutEntries = Array.from({ length: entries }, (_, i) => + getRandomIntInclusive(1, 100) + ); + layoutManager.setLayoutEntries(layoutEntries); + + // Verify that every entry is correct. + for (let entry = 0, start = 0; entry < entries; entry++) { + // Get the size of the entry. + const size = layoutEntries[entry]; + + // Verify that every offset for every entry is correct. + for (let offset = 0; offset < size; offset++) { + const layoutEntry = layoutManager.findLayoutEntry(start + offset); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, entry); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.end, start + size); + } + + // Adjust the start for the next entry. + start += size; + } + + // Override the first entry. + layoutManager.setLayoutOverride(0, 10); + + // Verify that every entry is correct. + for (let entry = 0, start = 0; entry < entries; entry++) { + // Get the size of the entry. + const size = !entry ? 10 : layoutEntries[entry]; + + // Verify that every offset for every entry is correct. + for (let offset = 0; offset < size; offset++) { + const layoutEntry = layoutManager.findLayoutEntry(start + offset); + assert(layoutEntry); + assert.strictEqual(layoutEntry!.index, entry); + assert.strictEqual(layoutEntry!.start, start); + assert.strictEqual(layoutEntry!.end, start + size); + } + + // Adjust the start for the next entry. + start += size; + } + }; + + /** + * Verify entries that should not be found. + * @param layoutManager The layout manager. + * @param entries The number of entries. + * @param size The size of each entry. + */ + const verifyEntriesThatShouldNotBeFound = ( + layoutManager: LayoutManager, + entries: number, + size: number + ) => { + // Verify that entries outside the range are not found. + assert(!layoutManager.findLayoutEntry(-1)); + assert(!layoutManager.findLayoutEntry(entries * size)); + assert(!layoutManager.findLayoutEntry((entries * size) + 100)); + assert(!layoutManager.findLayoutEntry((entries * size) + 1000)); + }; + + /** + * Gets a random integer in the inclusive range. + */ + const getRandomIntInclusive = (min: number, max: number) => { + const minCeiled = Math.ceil(min); + const maxFloored = Math.floor(max); + return Math.floor(Math.random() * (maxFloored - minCeiled + 1) + minCeiled); + }; + + // Ensure that all disposables are cleaned up. + ensureNoDisposablesAreLeakedInTestSuite(); +});