diff --git a/src/sql/base/browser/ui/scrollableSplitview/heightMap.ts b/src/sql/base/browser/ui/scrollableSplitview/heightMap.ts deleted file mode 100644 index 1ae3701c4f..0000000000 --- a/src/sql/base/browser/ui/scrollableSplitview/heightMap.ts +++ /dev/null @@ -1,214 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { INavigator } from 'vs/base/common/navigator'; - -export interface IView { - id?: string; -} - -export interface IViewItem { - view: IView; - top: number; - height: number; - width: number; -} - -export class HeightMap { - - private heightMap: IViewItem[]; - private indexes: { [item: string]: number; }; - - constructor() { - this.heightMap = []; - this.indexes = {}; - } - - public getContentHeight(): number { - let last = this.heightMap[this.heightMap.length - 1]; - return !last ? 0 : last.top + last.height; - } - - public onInsertItems(iterator: INavigator, afterItemId: string | null = null): number | undefined { - let viewItem: IViewItem | null = null; - let i: number, j: number; - let totalSize: number; - let sizeDiff = 0; - - if (afterItemId === null) { - i = 0; - totalSize = 0; - } else { - i = this.indexes[afterItemId!] + 1; - viewItem = this.heightMap[i - 1]; - - if (!viewItem) { - // eslint-disable-next-line no-console - console.error('view item doesnt exist'); - return undefined; - } - - totalSize = viewItem.top + viewItem.height; - } - - const startingIndex = i; - - let itemsToInsert: IViewItem[] = []; - - while (viewItem = iterator.next()) { - viewItem.top = totalSize + sizeDiff; - - this.indexes[viewItem.view.id!] = i++; - itemsToInsert.push(viewItem); - sizeDiff += viewItem.height; - } - - this.heightMap.splice(startingIndex, 0, ...itemsToInsert); - - for (j = i; j < this.heightMap.length; j++) { - viewItem = this.heightMap[j]; - viewItem.top += sizeDiff; - this.indexes[viewItem.view.id!] = j; - } - - for (j = itemsToInsert.length - 1; j >= 0; j--) { - this.onInsertItem(itemsToInsert[j]); - } - - for (j = this.heightMap.length - 1; j >= i; j--) { - this.onRefreshItem(this.heightMap[j]); - } - - return sizeDiff; - } - - public onInsertItem(item: IViewItem): void { - // noop - } - - // Contiguous items - public onRemoveItems(iterator: INavigator): void { - let itemId: string | null = null; - let viewItem: IViewItem; - let startIndex: number | null = null; - let i = 0; - let sizeDiff = 0; - - while (itemId = iterator.next()) { - i = this.indexes[itemId]; - viewItem = this.heightMap[i]; - - if (!viewItem) { - // eslint-disable-next-line no-console - console.error('view item doesnt exist'); - return; - } - - sizeDiff -= viewItem.height; - delete this.indexes[itemId]; - this.onRemoveItem(viewItem); - - if (startIndex === null) { - startIndex = i; - } - } - - if (sizeDiff === 0 || startIndex === null) { - return; - } - - this.heightMap.splice(startIndex, i - startIndex + 1); - - for (i = startIndex; i < this.heightMap.length; i++) { - viewItem = this.heightMap[i]; - viewItem.top += sizeDiff; - this.indexes[viewItem.view.id!] = i; - this.onRefreshItem(viewItem); - } - } - - public onRemoveItem(item: IViewItem): void { - // noop - } - - public onRefreshItem(item: IViewItem, needsRender: boolean = false): void { - // noop - } - - protected updateSize(item: string, size: number): void { - let i = this.indexes[item]; - - let viewItem = this.heightMap[i]; - - viewItem.height = size; - } - - protected updateTop(item: string, top: number): void { - let i = this.indexes[item]; - - let viewItem = this.heightMap[i]; - - viewItem.top = top; - } - - public itemsCount(): number { - return this.heightMap.length; - } - - public itemAt(position: number): string { - return this.heightMap[this.indexAt(position)].view.id!; - } - - public withItemsInRange(start: number, end: number, fn: (item: string) => void): void { - start = this.indexAt(start); - end = this.indexAt(end); - for (let i = start; i <= end; i++) { - fn(this.heightMap[i].view.id!); - } - } - - public indexAt(position: number): number { - let left = 0; - let right = this.heightMap.length; - let center: number; - let item: IViewItem; - - // Binary search - while (left < right) { - center = Math.floor((left + right) / 2); - item = this.heightMap[center]; - - if (position < item.top) { - right = center; - } else if (position >= item.top + item.height) { - if (left === center) { - break; - } - left = center; - } else { - return center; - } - } - - return this.heightMap.length; - } - - public indexAfter(position: number): number { - return Math.min(this.indexAt(position) + 1, this.heightMap.length); - } - - public itemAtIndex(index: number): IViewItem { - return this.heightMap[index]; - } - - public itemAfter(item: IViewItem): IViewItem { - return this.heightMap[this.indexes[item.view.id!] + 1] || null; - } - - public dispose(): void { - this.heightMap = []; - this.indexes = {}; - } -} diff --git a/src/sql/base/browser/ui/scrollableSplitview/scrollableSplitview.ts b/src/sql/base/browser/ui/scrollableSplitview/scrollableSplitview.ts deleted file mode 100644 index c18ae84014..0000000000 --- a/src/sql/base/browser/ui/scrollableSplitview/scrollableSplitview.ts +++ /dev/null @@ -1,901 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import 'vs/css!./media/scrollableSplitview'; -import { HeightMap, IView as HeightIView, IViewItem as HeightIViewItem } from './heightMap'; - -import { IDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { Emitter, Event } from 'vs/base/common/event'; -import * as types from 'vs/base/common/types'; -import * as dom from 'vs/base/browser/dom'; -import { clamp } from 'vs/base/common/numbers'; -import { range, firstIndex, pushToStart } from 'vs/base/common/arrays'; -import { Sash, Orientation, ISashEvent as IBaseSashEvent } from 'vs/base/browser/ui/sash/sash'; -import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; -import { ArrayNavigator } from 'vs/base/common/navigator'; -import { mixin } from 'vs/base/common/objects'; -import { ScrollbarVisibility } from 'vs/base/common/scrollable'; -import { ISplitViewStyles, Sizing } from 'vs/base/browser/ui/splitview/splitview'; -import { Color } from 'vs/base/common/color'; -import { domEvent } from 'vs/base/browser/event'; -import { generateUuid } from 'vs/base/common/uuid'; -export { Orientation } from 'vs/base/browser/ui/sash/sash'; - -export interface ISplitViewOptions { - orientation?: Orientation; // default Orientation.VERTICAL - styles?: ISplitViewStyles; - orthogonalStartSash?: Sash; - orthogonalEndSash?: Sash; - inverseAltBehavior?: boolean; - enableResizing?: boolean; - scrollDebounce?: number; - verticalScrollbarVisibility?: ScrollbarVisibility; -} - -const defaultStyles: ISplitViewStyles = { - separatorBorder: Color.transparent -}; - -const defaultOptions: ISplitViewOptions = { - enableResizing: false -}; - -export interface IView extends HeightIView { - readonly element: HTMLElement; - readonly minimumSize: number; - readonly maximumSize: number; - readonly onDidChange: Event; - layout(size: number, orientation: Orientation): void; - onAdd?(): void; - onRemove?(): void; -} - -interface ISashEvent { - sash: Sash; - start: number; - current: number; - alt: boolean; -} - -interface IViewItem extends HeightIViewItem { - view: IView; - size: number; - container: HTMLElement; - disposable: IDisposable; - layout(): void; - onRemove: () => void; - onAdd: () => void; -} - -interface ISashItem { - sash: Sash; - disposable: IDisposable; -} - -interface ISashDragState { - index: number; - start: number; - current: number; - sizes: number[]; - minDelta: number; - maxDelta: number; - alt: boolean; - disposable: IDisposable; -} - -enum State { - Idle, - Busy -} - -function pushToEnd(arr: T[], value: T): T[] { - let didFindValue = false; - - const result = arr.filter(v => { - if (v === value) { - didFindValue = true; - return false; - } - - return true; - }); - - if (didFindValue) { - result.push(value); - } - - return result; -} - -export class ScrollableSplitView extends HeightMap implements IDisposable { - - private orientation: Orientation; - private el: HTMLElement; - private sashContainer: HTMLElement; - private viewContainer: HTMLElement; - private scrollable: ScrollableElement; - private size = 0; - private contentSize = 0; - private proportions: undefined | number[] = undefined; - private viewItems: IViewItem[] = []; - private sashItems: ISashItem[] = []; - private sashDragState?: ISashDragState; - private state: State = State.Idle; - private inverseAltBehavior: boolean; - - private lastRenderHeight?: number; - private lastRenderTop?: number; - - private options: ISplitViewOptions; - - private dirtyState = false; - - private _onDidSashChange = new Emitter(); - readonly onDidSashChange = this._onDidSashChange.event; - private _onDidSashReset = new Emitter(); - readonly onDidSashReset = this._onDidSashReset.event; - - private _onScroll = new Emitter(); - readonly onScroll = this._onScroll.event; - - get length(): number { - return this.viewItems.length; - } - - get minimumSize(): number { - return this.viewItems.reduce((r, item) => r + item.view.minimumSize, 0); - } - - get maximumSize(): number { - return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.view.maximumSize, 0); - } - - private _orthogonalStartSash: Sash | undefined; - get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; } - set orthogonalStartSash(sash: Sash | undefined) { - for (const sashItem of this.sashItems) { - sashItem.sash.orthogonalStartSash = sash; - } - - this._orthogonalStartSash = sash; - } - - private _orthogonalEndSash: Sash | undefined; - get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; } - set orthogonalEndSash(sash: Sash | undefined) { - for (const sashItem of this.sashItems) { - sashItem.sash.orthogonalEndSash = sash; - } - - this._orthogonalEndSash = sash; - } - - get sashes(): Sash[] { - return this.sashItems.map(s => s.sash); - } - - constructor(container: HTMLElement, options: ISplitViewOptions = {}) { - super(); - this.orientation = types.isUndefined(options.orientation) ? Orientation.VERTICAL : options.orientation; - this.inverseAltBehavior = !!options.inverseAltBehavior; - - this.options = mixin(options, defaultOptions, false); - - this.el = document.createElement('div'); - this.scrollable = new ScrollableElement(this.el, { vertical: options.verticalScrollbarVisibility }); - Event.debounce(this.scrollable.onScroll, (l, e) => e, types.isNumber(this.options.scrollDebounce) ? this.options.scrollDebounce : 25)(e => { - this.render(e.scrollTop, e.height); - this.relayout(); - this._onScroll.fire(e.scrollTop); - }); - let domNode = this.scrollable.getDomNode(); - dom.addClass(this.el, 'monaco-scroll-split-view'); - dom.addClass(domNode, 'monaco-split-view2'); - dom.addClass(domNode, this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal'); - container.appendChild(domNode); - - this.sashContainer = dom.append(this.el, dom.$('.sash-container')); - this.viewContainer = dom.append(this.el, dom.$('.split-view-container')); - - this.style(options.styles || defaultStyles); - } - - style(styles: ISplitViewStyles): void { - if (styles.separatorBorder.isTransparent()) { - dom.removeClass(this.el, 'separator-border'); - this.el.style.removeProperty('--separator-border'); - } else { - dom.addClass(this.el, 'separator-border'); - this.el.style.setProperty('--separator-border', styles.separatorBorder.toString()); - } - } - - addViews(views: IView[], sizes: number[] | Sizing, index = this.viewItems.length): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - let currentIndex = index; - for (let i = 0; i < views.length; i++) { - let size: number | Sizing; - if (Array.isArray(sizes)) { - size = sizes[i]; - } else { - size = sizes; - } - const view = views[i]; - view.id = view.id || generateUuid(); - // Add view - const container = dom.$('.split-view-view'); - - // removed default adding of the view directly to the container - - const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size)); - const containerDisposable = toDisposable(() => { - if (container.parentElement) { - this.viewContainer.removeChild(container); - } - this.onRemoveItems(new ArrayNavigator([item.view.id!])); - }); - const disposable = combinedDisposable(onChangeDisposable, containerDisposable); - - const onAdd = view.onAdd ? () => view.onAdd!() : () => { }; - const onRemove = view.onRemove ? () => view.onRemove!() : () => { }; - - const layoutContainer = this.orientation === Orientation.VERTICAL - ? () => item.container.style.height = `${item.size}px` - : () => item.container.style.width = `${item.size}px`; - - const layout = () => { - layoutContainer(); - item.view.layout(item.size, this.orientation); - }; - - let viewSize: number; - - if (typeof size === 'number') { - viewSize = size; - } else if (size.type === 'split') { - viewSize = this.getViewSize(size.index) / 2; - } else { - viewSize = view.minimumSize; - } - - const item: IViewItem = { onAdd, onRemove, view, container, size: viewSize, layout, disposable, height: viewSize, top: 0, width: 0 }; - this.viewItems.splice(currentIndex, 0, item); - - this.onInsertItems(new ArrayNavigator([item]), currentIndex > 0 ? this.viewItems[currentIndex - 1].view.id : undefined); - - // Add sash - if (this.options.enableResizing && this.viewItems.length > 1) { - const sash = this.orientation === Orientation.HORIZONTAL - ? new Sash(this.sashContainer, { getHorizontalSashTop: (sash: Sash) => this.getSashPosition(sash) }, { - orientation: Orientation.HORIZONTAL, - orthogonalStartSash: this.orthogonalStartSash, - orthogonalEndSash: this.orthogonalEndSash - }) - : new Sash(this.sashContainer, { getVerticalSashLeft: (sash: Sash) => this.getSashPosition(sash) }, { - orientation: Orientation.VERTICAL, - orthogonalStartSash: this.orthogonalStartSash, - orthogonalEndSash: this.orthogonalEndSash - }); - - const sashEventMapper = this.orientation === Orientation.VERTICAL - ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey } as ISashEvent) - : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey } as ISashEvent); - - const onStart = Event.map(sash.onDidStart, sashEventMapper); - const onStartDisposable = onStart(this.onSashStart, this); - const onChange = Event.map(sash.onDidChange, sashEventMapper); - const onChangeDisposable = onChange(this.onSashChange, this); - const onEnd = Event.map(sash.onDidEnd, () => firstIndex(this.sashItems, item => item.sash === sash)); - const onEndDisposable = onEnd(this.onSashEnd, this); - const onDidResetDisposable = sash.onDidReset(() => this._onDidSashReset.fire(firstIndex(this.sashItems, item => item.sash === sash))); - - const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash); - const sashItem: ISashItem = { sash, disposable }; - - this.sashItems.splice(currentIndex - 1, 0, sashItem); - } - - container.appendChild(view.element); - currentIndex++; - } - - let highPriorityIndex: number | undefined; - - if (!types.isArray(sizes) && sizes.type === 'split') { - highPriorityIndex = sizes.index; - } - - this.relayout(index, highPriorityIndex); - this.state = State.Idle; - - if (!types.isArray(sizes) && sizes.type === 'distribute') { - this.distributeViewSizes(); - } - - // Re-render the views. Set lastRenderTop and lastRenderHeight to undefined since - // this isn't actually scrolling up or down - let scrollTop = this.lastRenderTop!; - let viewHeight = this.lastRenderHeight!; - this.lastRenderTop = 0; - this.lastRenderHeight = 0; - this.render(scrollTop, viewHeight); - } - - addView(view: IView, size: number | Sizing, index = this.viewItems.length): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - view.id = view.id || generateUuid(); - // Add view - const container = dom.$('.split-view-view'); - - // removed default adding of the view directly to the container - - const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size)); - const containerDisposable = toDisposable(() => { - if (container.parentElement) { - this.viewContainer.removeChild(container); - } - this.onRemoveItems(new ArrayNavigator([item.view.id!])); - }); - const disposable = combinedDisposable(onChangeDisposable, containerDisposable); - - const onAdd = view.onAdd ? () => view.onAdd!() : () => { }; - const onRemove = view.onRemove ? () => view.onRemove!() : () => { }; - - const layoutContainer = this.orientation === Orientation.VERTICAL - ? () => item.container.style.height = `${item.size}px` - : () => item.container.style.width = `${item.size}px`; - - const layout = () => { - layoutContainer(); - item.view.layout(item.size, this.orientation); - }; - - let viewSize: number; - - if (typeof size === 'number') { - viewSize = size; - } else if (size.type === 'split') { - viewSize = this.getViewSize(size.index) / 2; - } else { - viewSize = view.minimumSize; - } - - const item: IViewItem = { onAdd, onRemove, view, container, size: viewSize, layout, disposable, height: viewSize, top: 0, width: 0 }; - this.viewItems.splice(index, 0, item); - - this.onInsertItems(new ArrayNavigator([item]), index > 0 ? this.viewItems[index - 1].view.id : undefined); - - // Add sash - if (this.options.enableResizing && this.viewItems.length > 1) { - const sash = this.orientation === Orientation.HORIZONTAL - ? new Sash(this.sashContainer, { getHorizontalSashTop: (sash: Sash) => this.getSashPosition(sash) }, { - orientation: Orientation.HORIZONTAL, - orthogonalStartSash: this.orthogonalStartSash, - orthogonalEndSash: this.orthogonalEndSash - }) - : new Sash(this.sashContainer, { getVerticalSashLeft: (sash: Sash) => this.getSashPosition(sash) }, { - orientation: Orientation.VERTICAL, - orthogonalStartSash: this.orthogonalStartSash, - orthogonalEndSash: this.orthogonalEndSash - }); - - const sashEventMapper = this.orientation === Orientation.VERTICAL - ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey } as ISashEvent) - : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey } as ISashEvent); - - const onStart = Event.map(sash.onDidStart, sashEventMapper); - const onStartDisposable = onStart(this.onSashStart, this); - const onChange = Event.map(sash.onDidChange, sashEventMapper); - const onChangeDisposable = onChange(this.onSashChange, this); - const onEnd = Event.map(sash.onDidEnd, () => firstIndex(this.sashItems, item => item.sash === sash)); - const onEndDisposable = onEnd(this.onSashEnd, this); - const onDidResetDisposable = sash.onDidReset(() => this._onDidSashReset.fire(firstIndex(this.sashItems, item => item.sash === sash))); - - const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash); - const sashItem: ISashItem = { sash, disposable }; - - this.sashItems.splice(index - 1, 0, sashItem); - } - - container.appendChild(view.element); - - let highPriorityIndex: number | undefined; - - if (typeof size !== 'number' && size.type === 'split') { - highPriorityIndex = size.index; - } - - this.relayout(index, highPriorityIndex); - this.state = State.Idle; - - if (typeof size !== 'number' && size.type === 'distribute') { - this.distributeViewSizes(); - } - } - - clear(): void { - for (let i = this.viewItems.length - 1; i >= 0; i--) { - this.removeView(i); - } - } - - removeView(index: number, sizing?: Sizing): IView { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - if (index < 0 || index >= this.viewItems.length) { - throw new Error('Index out of bounds'); - } - - // Remove view - const viewItem = this.viewItems.splice(index, 1)[0]; - viewItem.disposable.dispose(); - - // Remove sash - if (this.options.enableResizing && this.viewItems.length >= 1) { - const sashIndex = Math.max(index - 1, 0); - const sashItem = this.sashItems.splice(sashIndex, 1)[0]; - sashItem.disposable.dispose(); - } - - this.relayout(); - this.state = State.Idle; - - if (sizing && sizing.type === 'distribute') { - this.distributeViewSizes(); - } - - return viewItem.view; - } - - moveView(from: number, to: number): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - const size = this.getViewSize(from); - const view = this.removeView(from); - this.addView(view, size, to); - } - - swapViews(from: number, to: number): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - if (from > to) { - return this.swapViews(to, from); - } - - const fromSize = this.getViewSize(from); - const toSize = this.getViewSize(to); - const toView = this.removeView(to); - const fromView = this.removeView(from); - - this.addView(toView, fromSize, from); - this.addView(fromView, toSize, to); - } - - private relayout(lowPriorityIndex?: number, highPriorityIndex?: number): void { - const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - - this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndex, highPriorityIndex); - this.distributeEmptySpace(); - this.layoutViews(); - this.saveProportions(); - } - - public setScrollPosition(position: number) { - this.scrollable.setScrollPosition({ scrollTop: position }); - } - - layout(size: number): void { - const previousSize = Math.max(this.size, this.contentSize); - this.size = size; - this.contentSize = 0; - this.lastRenderHeight = 0; - this.lastRenderTop = 0; - - if (!this.proportions) { - this.resize(this.viewItems.length - 1, size - previousSize); - } else { - for (let i = 0; i < this.viewItems.length; i++) { - const item = this.viewItems[i]; - item.size = clamp(Math.round(this.proportions[i] * size), item.view.minimumSize, item.view.maximumSize); - this.updateSize(item.view.id!, size); - } - } - - this.distributeEmptySpace(); - this.layoutViews(); - } - - private saveProportions(): void { - if (this.contentSize > 0) { - this.proportions = this.viewItems.map(i => i.size / this.contentSize); - } - } - - private onSashStart({ sash, start, alt }: ISashEvent): void { - const index = firstIndex(this.sashItems, item => item.sash === sash); - - // This way, we can press Alt while we resize a sash, macOS style! - const disposable = combinedDisposable( - domEvent(document.body, 'keydown')(e => resetSashDragState(this.sashDragState!.current, e.altKey)), - domEvent(document.body, 'keyup')(() => resetSashDragState(this.sashDragState!.current, false)) - ); - - const resetSashDragState = (start: number, alt: boolean) => { - const sizes = this.viewItems.map(i => i.size); - let minDelta = Number.NEGATIVE_INFINITY; - let maxDelta = Number.POSITIVE_INFINITY; - - if (this.inverseAltBehavior) { - alt = !alt; - } - - if (alt) { - // When we're using the last sash with Alt, we're resizing - // the view to the left/up, instead of right/down as usual - // Thus, we must do the inverse of the usual - const isLastSash = index === this.sashItems.length - 1; - - if (isLastSash) { - const viewItem = this.viewItems[index]; - minDelta = (viewItem.view.minimumSize - viewItem.size) / 2; - maxDelta = (viewItem.view.maximumSize - viewItem.size) / 2; - } else { - const viewItem = this.viewItems[index + 1]; - minDelta = (viewItem.size - viewItem.view.maximumSize) / 2; - maxDelta = (viewItem.size - viewItem.view.minimumSize) / 2; - } - } - - this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, disposable }; - }; - - resetSashDragState(start, alt); - } - - private onSashChange({ current }: ISashEvent): void { - const { index, start, sizes, alt, minDelta, maxDelta } = this.sashDragState!; - this.sashDragState!.current = current; - - const delta = current - start; - const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta); - - if (alt) { - const isLastSash = index === this.sashItems.length - 1; - const newSizes = this.viewItems.map(i => i.size); - const viewItemIndex = isLastSash ? index : index + 1; - const viewItem = this.viewItems[viewItemIndex]; - const newMinDelta = viewItem.size - viewItem.view.maximumSize; - const newMaxDelta = viewItem.size - viewItem.view.minimumSize; - const resizeIndex = isLastSash ? index - 1 : index + 1; - - this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta); - } - - this.distributeEmptySpace(); - this.layoutViews(); - } - - private onSashEnd(index: number): void { - this._onDidSashChange.fire(index); - this.sashDragState!.disposable.dispose(); - this.saveProportions(); - } - - private onViewChange(item: IViewItem, size: number | undefined): void { - const index = this.viewItems.indexOf(item); - - if (index < 0 || index >= this.viewItems.length) { - return; - } - - size = typeof size === 'number' ? size : item.size; - size = clamp(size, item.view.minimumSize, item.view.maximumSize); - - if (this.inverseAltBehavior && index > 0) { - // In this case, we want the view to grow or shrink both sides equally - // so we just resize the "left" side by half and let `resize` do the clamping magic - this.resize(index - 1, Math.floor((item.size - size) / 2)); - this.distributeEmptySpace(); - this.layoutViews(); - } else { - item.size = size; - this.updateSize(item.view.id!, size); - let top: number = 0; - for (let i = 0; i < this.viewItems.length; i++) { - let currentItem: IViewItem = this.viewItems[i]; - this.updateTop(currentItem.view.id!, top); - top += currentItem.size; - } - this.relayout(index); - } - } - - resizeView(index: number, size: number): void { - if (this.state !== State.Idle) { - throw new Error('Cant modify splitview'); - } - - this.state = State.Busy; - - if (index < 0 || index >= this.viewItems.length) { - return; - } - - const item = this.viewItems[index]; - size = Math.round(size); - size = clamp(size, item.view.minimumSize, item.view.maximumSize); - let delta = size - item.size; - - if (delta !== 0 && index < this.viewItems.length - 1) { - const downIndexes = range(index + 1, this.viewItems.length); - const collapseDown = downIndexes.reduce((r, i) => r + (this.viewItems[i].size - this.viewItems[i].view.minimumSize), 0); - const expandDown = downIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - this.viewItems[i].size), 0); - const deltaDown = clamp(delta, -expandDown, collapseDown); - - this.resize(index, deltaDown); - delta -= deltaDown; - } - - if (delta !== 0 && index > 0) { - const upIndexes = range(index - 1, -1); - const collapseUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].size - this.viewItems[i].view.minimumSize), 0); - const expandUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - this.viewItems[i].size), 0); - const deltaUp = clamp(-delta, -collapseUp, expandUp); - - this.resize(index - 1, deltaUp); - } - - this.distributeEmptySpace(); - this.layoutViews(); - this.saveProportions(); - this.state = State.Idle; - } - - distributeViewSizes(): void { - const size = Math.floor(this.size / this.viewItems.length); - - for (let i = 0; i < this.viewItems.length - 1; i++) { - this.resizeView(i, size); - } - } - - getViewSize(index: number): number { - if (index < 0 || index >= this.viewItems.length) { - return -1; - } - - return this.viewItems[index].size; - } - - - private render(scrollTop: number, viewHeight: number): void { - let i: number; - let stop: number; - - let renderTop = scrollTop; - let renderBottom = scrollTop + viewHeight; - let thisRenderBottom = this.lastRenderTop! + this.lastRenderHeight!; - - // when view scrolls down, start rendering from the renderBottom - for (i = this.indexAfter(renderBottom) - 1, stop = this.indexAt(Math.max(thisRenderBottom, renderTop)); i >= stop; i--) { - if (this.insertItemInDOM(this.itemAtIndex(i))) { - this.dirtyState = true; - } - } - - // when view scrolls up, start rendering from either this.renderTop or renderBottom - for (i = Math.min(this.indexAt(this.lastRenderTop!), this.indexAfter(renderBottom)) - 1, stop = this.indexAt(renderTop); i >= stop; i--) { - if (this.insertItemInDOM(this.itemAtIndex(i))) { - this.dirtyState = true; - } - } - - // when view scrolls down, start unrendering from renderTop - for (i = this.indexAt(this.lastRenderTop!), stop = Math.min(this.indexAt(renderTop), this.indexAfter(thisRenderBottom)); i < stop; i++) { - if (this.removeItemFromDOM(this.itemAtIndex(i))) { - this.dirtyState = true; - } - } - - // when view scrolls up, start unrendering from either renderBottom this.renderTop - for (i = Math.max(this.indexAfter(renderBottom), this.indexAt(this.lastRenderTop!)), stop = this.indexAfter(thisRenderBottom); i < stop; i++) { - if (this.removeItemFromDOM(this.itemAtIndex(i))) { - this.dirtyState = true; - } - } - - let topItem = this.itemAtIndex(this.indexAt(renderTop)); - - if (topItem) { - this.viewContainer.style.top = (topItem.top - renderTop) + 'px'; - } - - this.lastRenderTop = renderTop; - this.lastRenderHeight = renderBottom - renderTop; - } - - // DOM changes - - private insertItemInDOM(item: IViewItem): boolean { - if (item.container.parentElement) { - return false; - } - - let elementAfter: HTMLElement | undefined = undefined; - let itemAfter = this.itemAfter(item); - - if (itemAfter && itemAfter.container) { - elementAfter = itemAfter.container; - } - - if (elementAfter === undefined) { - this.viewContainer.appendChild(item.container); - } else { - try { - this.viewContainer.insertBefore(item.container, elementAfter); - } catch (e) { - // console.warn('Failed to locate previous tree element'); - this.viewContainer.appendChild(item.container); - } - } - - item.layout(); - - item.onAdd(); - return true; - } - - private removeItemFromDOM(item: IViewItem): boolean { - if (!item || !item.container || !item.container.parentElement) { - return false; - } - - this.viewContainer.removeChild(item.container); - - item.onRemove(); - return true; - } - - private resize( - index: number, - delta: number, - sizes = this.viewItems.map(i => i.size), - lowPriorityIndex?: number, - highPriorityIndex?: number, - overloadMinDelta: number = Number.NEGATIVE_INFINITY, - overloadMaxDelta: number = Number.POSITIVE_INFINITY - ): number { - if (index < 0 || index >= this.viewItems.length) { - return 0; - } - - const upIndexes = range(index, -1); - const downIndexes = range(index + 1, this.viewItems.length); - - if (typeof highPriorityIndex === 'number') { - pushToStart(upIndexes, highPriorityIndex); - pushToStart(downIndexes, highPriorityIndex); - } - - if (typeof lowPriorityIndex === 'number') { - pushToEnd(upIndexes, lowPriorityIndex); - pushToEnd(downIndexes, lowPriorityIndex); - } - - const upItems = upIndexes.map(i => this.viewItems[i]); - const upSizes = upIndexes.map(i => sizes[i]); - - const downItems = downIndexes.map(i => this.viewItems[i]); - const downSizes = downIndexes.map(i => sizes[i]); - - const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.minimumSize - sizes[i]), 0); - const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - sizes[i]), 0); - const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].view.minimumSize), 0); - const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].view.maximumSize), 0); - const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta); - const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta); - - delta = clamp(delta, minDelta, maxDelta); - - for (let i = 0, deltaUp = delta; i < upItems.length; i++) { - const item = upItems[i]; - const size = clamp(upSizes[i] + deltaUp, item.view.minimumSize, item.view.maximumSize); - const viewDelta = size - upSizes[i]; - - deltaUp -= viewDelta; - item.size = size; - this.updateSize(item.view.id!, size); - this.dirtyState = true; - } - - for (let i = 0, deltaDown = delta; i < downItems.length; i++) { - const item = downItems[i]; - const size = clamp(downSizes[i] - deltaDown, item.view.minimumSize, item.view.maximumSize); - const viewDelta = size - downSizes[i]; - - deltaDown += viewDelta; - item.size = size; - this.updateSize(item.view.id!, size); - this.dirtyState = true; - } - - return delta; - } - - private distributeEmptySpace(): void { - let contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - let emptyDelta = this.size - contentSize; - - for (let i = this.viewItems.length - 1; emptyDelta !== 0 && i >= 0; i--) { - const item = this.viewItems[i]; - const size = clamp(item.size + emptyDelta, item.view.minimumSize, item.view.maximumSize); - const viewDelta = size - item.size; - - emptyDelta -= viewDelta; - item.size = size; - this.updateSize(item.view.id!, size); - } - } - - private layoutViews(): void { - // Save new content size - this.contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - - if (this.dirtyState) { - for (let i = this.indexAt(this.lastRenderTop!); i <= this.indexAfter(this.lastRenderTop! + this.lastRenderHeight!) - 1; i++) { - this.viewItems[i].layout(); - if (this.options.enableResizing) { - this.sashItems[i].sash.layout(); - } - } - this.dirtyState = false; - } - - this.scrollable.setScrollDimensions({ - scrollHeight: this.contentSize, - height: this.size - }); - } - - private getSashPosition(sash: Sash): number { - let position = 0; - - for (let i = 0; i < this.sashItems.length; i++) { - position += this.viewItems[i].size; - - if (this.sashItems[i].sash === sash) { - return position; - } - } - - return 0; - } - - dispose(): void { - this.viewItems.forEach(i => i.disposable.dispose()); - this.viewItems = []; - - this.sashItems.forEach(i => i.disposable.dispose()); - this.sashItems = []; - } -} diff --git a/src/sql/base/browser/ui/scrollableSplitview/media/scrollableSplitview.css b/src/sql/base/browser/ui/scrollableView/scrollableView.css similarity index 60% rename from src/sql/base/browser/ui/scrollableSplitview/media/scrollableSplitview.css rename to src/sql/base/browser/ui/scrollableView/scrollableView.css index 69156dacd8..cfd9d10b0d 100644 --- a/src/sql/base/browser/ui/scrollableSplitview/media/scrollableSplitview.css +++ b/src/sql/base/browser/ui/scrollableView/scrollableView.css @@ -3,11 +3,22 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.split-view-container { +.scrollable-view { position: relative; -} - -.monaco-scroll-split-view { height: 100%; width: 100%; + white-space: nowrap; +} + +.scrollable-view .scrollable-view-container { + position: relative; + width: 100%; + height: 100%; +} + +.scrollable-view .scrollable-view-child { + position: absolute; + box-sizing: border-box; + overflow: hidden; + width: 100%; } diff --git a/src/sql/base/browser/ui/scrollableView/scrollableView.ts b/src/sql/base/browser/ui/scrollableView/scrollableView.ts new file mode 100644 index 0000000000..d5096a433f --- /dev/null +++ b/src/sql/base/browser/ui/scrollableView/scrollableView.ts @@ -0,0 +1,338 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./scrollableView'; + +import { RangeMap } from 'vs/base/browser/ui/list/rangeMap'; +import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { Scrollable, ScrollbarVisibility, INewScrollDimensions, ScrollEvent } from 'vs/base/common/scrollable'; +import { getOrDefault } from 'vs/base/common/objects'; +import * as DOM from 'vs/base/browser/dom'; +import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { domEvent } from 'vs/base/browser/event'; +import { Event } from 'vs/base/common/event'; +import { Range, IRange } from 'vs/base/common/range'; +import { clamp } from 'vs/base/common/numbers'; + +export interface IScrollableViewOptions { + useShadows?: boolean; + smoothScrolling?: boolean; + verticalScrollMode?: ScrollbarVisibility; + additionalScrollHeight?: number; +} + +const DefaultOptions: IScrollableViewOptions = { + useShadows: true, + verticalScrollMode: ScrollbarVisibility.Auto +}; + +export interface IView { + layout(height: number, width: number): void; + readonly onDidChange: Event; + readonly element: HTMLElement; + readonly minimumSize: number; + readonly maximumSize: number; + onDidInsert?(): void; + onDidRemove?(): void; +} + +interface IItem { + readonly view: IView; + size: number; + domNode?: HTMLElement; + onDidInsertDisposable?: IDisposable; // I don't trust the children + onDidRemoveDisposable?: IDisposable; // I don't trust the children + disposables: IDisposable[]; +} + +export class ScrollableView extends Disposable { + private readonly rangeMap = new RangeMap(); + private readonly scrollableElement: SmoothScrollableElement; + private readonly scrollable: Scrollable; + private readonly viewContainer = DOM.$('div.scrollable-view-container'); + private readonly domNode = DOM.$('div.scrollable-view'); + + private scrollableElementUpdateDisposable?: IDisposable; + private additionalScrollHeight: number; + private _scrollHeight = 0; + private renderHeight = 0; + private lastRenderTop = 0; + private lastRenderHeight = 0; + private readonly items: IItem[] = []; + + private width: number = 0; + + get contentHeight(): number { return this.rangeMap.size; } + get onDidScroll(): Event { return this.scrollableElement.onScroll; } + get length(): number { return this.items.length; } + + constructor(container: HTMLElement, options: IScrollableViewOptions = DefaultOptions) { + super(); + + this.additionalScrollHeight = typeof options.additionalScrollHeight === 'undefined' ? 0 : options.additionalScrollHeight; + + this.scrollable = new Scrollable(getOrDefault(options, o => o.smoothScrolling, false) ? 125 : 0, cb => DOM.scheduleAtNextAnimationFrame(cb)); + this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, { + alwaysConsumeMouseWheel: true, + horizontal: ScrollbarVisibility.Hidden, + vertical: getOrDefault(options, o => o.verticalScrollMode, DefaultOptions.verticalScrollMode), + useShadows: getOrDefault(options, o => o.useShadows, DefaultOptions.useShadows), + }, this.scrollable)); + this.domNode.appendChild(this.scrollableElement.getDomNode()); + container.appendChild(this.domNode); + + this._register(Event.debounce(this.scrollableElement.onScroll, (l, e) => e, 25)(this.onScroll, this)); + + // Prevent the monaco-scrollable-element from scrolling + // https://github.com/Microsoft/vscode/issues/44181 + this._register(domEvent(this.scrollableElement.getDomNode(), 'scroll') + (e => (e.target as HTMLElement).scrollTop = 0)); + } + + elementTop(index: number): number { + return this.rangeMap.positionAt(index); + } + + layout(height?: number, width?: number): void { + let scrollDimensions: INewScrollDimensions = { + height: typeof height === 'number' ? height : DOM.getContentHeight(this.domNode) + }; + + this.renderHeight = scrollDimensions.height; + + this.width = width ?? DOM.getContentWidth(this.domNode); + + this.calculateItemHeights(); + + if (this.scrollableElementUpdateDisposable) { + this.scrollableElementUpdateDisposable.dispose(); + this.scrollableElementUpdateDisposable = null; + scrollDimensions.scrollHeight = this.scrollHeight; + } + + this.scrollableElement.setScrollDimensions(scrollDimensions); + } + + setScrollTop(scrollTop: number): void { + if (this.scrollableElementUpdateDisposable) { + this.scrollableElementUpdateDisposable.dispose(); + this.scrollableElementUpdateDisposable = null; + this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight }); + } + + this.scrollableElement.setScrollPosition({ scrollTop }); + } + + private rerender(lastRenderRange: IRange) { + this.calculateItemHeights(); + this.render(lastRenderRange, this.lastRenderTop, this.lastRenderHeight, true); + + this.eventuallyUpdateScrollDimensions(); + } + + public addViews(views: IView[]): void { // @todo anthonydresser add ability to splice into the middle of the list and remove a particular index + const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + const items = views.map(view => ({ size: view.minimumSize, view, disposables: [], index: 0 })); + + items.map(i => i.disposables.push(i.view.onDidChange(() => this.rerender(this.getRenderRange(this.lastRenderTop, this.lastRenderHeight))))); + + // calculate heights + this.splice(this.items.length, 0, items); + this.rerender(lastRenderRange); + } + + private splice(index: number, deleteCount: number, items: IItem[] = []): IItem[] { + this.rangeMap.splice(index, deleteCount, items); + const ret = this.items.splice(index, deleteCount, ...items); + return ret; + } + + public addView(view: IView): void { + this.addViews([view]); + } + + /** + * Removes all views + */ + public clear(): void { + const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + for (const item of this.items) { + if (item.domNode) { + DOM.clearNode(item.domNode); + DOM.removeNode(item.domNode); + item.domNode = undefined; + } + dispose(item.disposables); + } + this.splice(0, this.items.length); + this.rerender(lastRenderRange); + } + + private calculateItemHeights() { + let currentMin = 0; + for (const item of this.items) { + currentMin += item.view.minimumSize; + if (currentMin > this.renderHeight) { + break; + } + } + if (currentMin > this.renderHeight) { // the items will fill the render height, so just use min heights + this.items.forEach((item, index) => { + if (item.size !== item.view.minimumSize) { + item.size = item.view.minimumSize; + this.rangeMap.splice(index, 1, [item]); + } + }); + } else { + // try to even distribute + let renderHeightRemaining = this.renderHeight; + this.items.forEach((item, index) => { + const desiredheight = Math.floor(renderHeightRemaining / (this.items.length - index)); + const newSize = clamp(desiredheight, item.view.minimumSize, item.view.maximumSize); + if (item.size !== newSize) { + item.size = newSize; + this.rangeMap.splice(index, 1, [item]); + } + renderHeightRemaining -= item.size; + }); + } + } + + get scrollHeight(): number { + return this._scrollHeight + this.additionalScrollHeight; + } + + private onScroll(e: ScrollEvent): void { + const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + this.render(previousRenderRange, e.scrollTop, e.height); + } + + private getRenderRange(renderTop: number, renderHeight: number): IRange { + return { + start: this.rangeMap.indexAt(renderTop), + end: this.rangeMap.indexAfter(renderTop + renderHeight - 1) + }; + } + + + // Render + + private render(previousRenderRange: IRange, renderTop: number, renderHeight: number, updateItemsInDOM: boolean = false): void { + const renderRange = this.getRenderRange(renderTop, renderHeight); + + const rangesToInsert = Range.relativeComplement(renderRange, previousRenderRange); + const rangesToRemove = Range.relativeComplement(previousRenderRange, renderRange); + const beforeElement = this.getNextToLastElement(rangesToInsert); + + if (updateItemsInDOM) { + const rangesToUpdate = Range.intersect(previousRenderRange, renderRange); + + for (let i = rangesToUpdate.start; i < rangesToUpdate.end; i++) { + this.updateItemInDOM(this.items[i], i); + } + } + + for (const range of rangesToInsert) { + for (let i = range.start; i < range.end; i++) { + this.insertItemInDOM(i, beforeElement); + } + } + + for (const range of rangesToRemove) { + for (let i = range.start; i < range.end; i++) { + this.removeItemFromDOM(i); + } + } + + this.viewContainer.style.top = `-${renderTop}px`; + + this.lastRenderTop = renderTop; + this.lastRenderHeight = renderHeight; + } + + // DOM operations + + private insertItemInDOM(index: number, beforeElement: HTMLElement | null): void { + const item = this.items[index]; + + if (!item.domNode) { + item.domNode = DOM.$('div.scrollable-view-child'); + item.domNode.appendChild(item.view.element); + } + + if (!item.domNode!.parentElement) { + if (beforeElement) { + this.viewContainer.insertBefore(item.domNode!, beforeElement); + } else { + this.viewContainer.appendChild(item.domNode!); + } + } + + this.updateItemInDOM(item, index, false); + + item.onDidRemoveDisposable?.dispose(); + item.onDidInsertDisposable = DOM.scheduleAtNextAnimationFrame(() => { + // we don't trust the items to be performant so don't interrupt our operations + if (item.view.onDidInsert) { + item.view.onDidInsert(); + } + item.view.layout(item.size, this.width); + }); + } + + private updateItemInDOM(item: IItem, index: number, layout: boolean = true): void { + item.domNode!.style.top = `${this.elementTop(index)}px`; + item.domNode!.style.width = `${this.width}px`; + item.domNode!.style.height = `${item.size}px`; + if (layout) { + DOM.scheduleAtNextAnimationFrame(() => { + item.view.layout(item.size, this.width); + }); + } + } + + private removeItemFromDOM(index: number): void { + const item = this.items[index]; + + if (item) { + item.domNode.remove(); + item.onDidInsertDisposable?.dispose(); + if (item.view.onDidRemove) { + item.onDidRemoveDisposable = DOM.scheduleAtNextAnimationFrame(() => { + // we don't trust the items to be performant so don't interrupt our operations + item.view.onDidRemove(); + }); + } + } + } + + private getNextToLastElement(ranges: IRange[]): HTMLElement | null { + const lastRange = ranges[ranges.length - 1]; + + if (!lastRange) { + return null; + } + + const nextToLastItem = this.items[lastRange.end]; + + if (!nextToLastItem) { + return null; + } + + return nextToLastItem.domNode; + } + + private eventuallyUpdateScrollDimensions(): void { + this._scrollHeight = this.contentHeight; + this.viewContainer.style.height = `${this._scrollHeight}px`; + + if (!this.scrollableElementUpdateDisposable) { + this.scrollableElementUpdateDisposable = DOM.scheduleAtNextAnimationFrame(() => { + this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight }); + this.scrollableElementUpdateDisposable = null; + }); + } + } +} diff --git a/src/sql/base/test/browser/ui/scrollableView/scrollableView.test.ts b/src/sql/base/test/browser/ui/scrollableView/scrollableView.test.ts new file mode 100644 index 0000000000..e30080ef28 --- /dev/null +++ b/src/sql/base/test/browser/ui/scrollableView/scrollableView.test.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IView, ScrollableView } from 'sql/base/browser/ui/scrollableView/scrollableView'; +import { Emitter } from 'vs/base/common/event'; +import * as assert from 'assert'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { timeout } from 'vs/base/common/async'; + +class TestView extends Disposable implements IView { + + constructor(public minimumSize: number, public maximumSize: number) { super(); } + + private readonly _onDidLayout = this._register(new Emitter<{ height: number, width: number }>()); + public readonly onDidLayout = this._onDidLayout.event; + layout(height: number, width: number): void { + this._size = height; + this._onDidLayout.fire({ height, width }); + } + + private _size: number; + public get size(): number { + return this._size; + } + + public readonly onDidChangeEmitter = this._register(new Emitter()); + public readonly onDidChange = this.onDidChangeEmitter.event; + + public readonly element = document.createElement('div'); + + private readonly _onDidInsertEmitter = this._register(new Emitter()); + public readonly onDidInsertEvent = this._onDidInsertEmitter.event; + onDidInsert?(): void { + this._onDidInsertEmitter.fire(); + } + + private readonly _onDidRemoveEmitter = this._register(new Emitter()); + public readonly onDidRemoveEvent = this._onDidRemoveEmitter.event; + onDidRemove?(): void { + this._onDidRemoveEmitter.fire(); + } + + +} + +suite('ScrollableView', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.width = `${200}px`; + container.style.height = `${200}px`; + }); + + test('creates empty view', () => { + const scrollableView = new ScrollableView(container); + scrollableView.layout(200, 200); + assert.equal(container.firstElementChild!.firstElementChild!.firstElementChild!.childElementCount, 0, 'view should be empty'); + scrollableView.dispose(); + }); + + test('adds and removes views correctly', async () => { + const view1 = new TestView(20, 20); + const view2 = new TestView(20, 20); + const view3 = new TestView(20, 20); + const scrollableView = new ScrollableView(container); + + scrollableView.layout(200, 200); + + scrollableView.addView(view1); + scrollableView.addView(view2); + scrollableView.addView(view3); + + // we only update the scroll dimensions asynchronously + await waitForAnimation(); + + let viewQuery = getViewChildren(container); + assert.equal(viewQuery.length, 3, 'split view should have 3 views'); + + scrollableView.clear(); + + viewQuery = getViewChildren(container); + assert.equal(viewQuery.length, 0, 'split view should have no views'); + + view1.dispose(); + view2.dispose(); + view3.dispose(); + scrollableView.dispose(); + }); + + test('shrinks views correctly', async () => { + const view1 = new TestView(20, Number.POSITIVE_INFINITY); + const view2 = new TestView(20, Number.POSITIVE_INFINITY); + const view3 = new TestView(20, Number.POSITIVE_INFINITY); + const scrollableView = new ScrollableView(container); + + scrollableView.layout(200, 200); + + scrollableView.addView(view1); + + await waitForAnimation(); + + assert.equal(view1.size, 200, 'view1 is entire size'); + + scrollableView.addView(view2); + + await waitForAnimation(); + + assert.equal(view1.size, 100, 'view1 is half size'); + assert.equal(view2.size, 100, 'view2 is half size'); + + scrollableView.addView(view3); + + await waitForAnimation(); + + assert.equal(view1.size, 66, 'view1 is third size'); + assert.equal(view2.size, 67, 'view2 is third size'); + assert.equal(view3.size, 67, 'view3 is third size'); + }); + + test('honors minimum size', async () => { + const view1 = new TestView(100, Number.POSITIVE_INFINITY); + const view2 = new TestView(100, Number.POSITIVE_INFINITY); + const view3 = new TestView(100, Number.POSITIVE_INFINITY); + const scrollableView = new ScrollableView(container); + + scrollableView.layout(200, 200); + + scrollableView.addViews([view1, view2, view3]); + + await waitForAnimation(); + + assert.equal(view1.size, 100, 'view1 is minimum size'); + assert.equal(view2.size, 100, 'view2 is minimum size'); + assert.equal(view3.size, undefined, 'view3 should not have been layout yet'); + }); + + test('reacts to changes in views', async () => { + const view1 = new TestView(100, Number.POSITIVE_INFINITY); + const view2 = new TestView(100, Number.POSITIVE_INFINITY); + const view3 = new TestView(100, Number.POSITIVE_INFINITY); + const scrollableView = new ScrollableView(container); + + scrollableView.layout(200, 200); + + scrollableView.addViews([view1, view2, view3]); + + await waitForAnimation(); + + view1.minimumSize = 130; + view1.onDidChangeEmitter.fire(0); + + await waitForAnimation(); + + assert.equal(view1.size, 130, 'view1 should be 130'); + assert.equal(view2.size, 100, 'view2 should still be minimum size'); + assert.equal(view3.size, undefined, 'view3 should not have been layout yet'); + }); + + test('programmatically scrolls', async () => { + const view1 = new TestView(100, Number.POSITIVE_INFINITY); + const view2 = new TestView(100, Number.POSITIVE_INFINITY); + const view3 = new TestView(100, Number.POSITIVE_INFINITY); + const scrollableView = new ScrollableView(container); + + scrollableView.layout(200, 200); + + scrollableView.addViews([view1, view2, view3]); + + await waitForAnimation(); + + assert.equal(view1.size, 100, 'view1 is minimum size'); + assert.equal(view2.size, 100, 'view2 is minimum size'); + assert.equal(view3.size, undefined, 'view3 should not have been layout yet'); + assert.equal(getViewChildren(container).length, 2, 'only 2 views are rendered'); + + scrollableView.setScrollTop(100); + + await waitForAnimation(); + assert.equal(view2.size, 100, 'view2 is minimum size'); + assert.equal(view3.size, 100, 'view3 is minimum size'); + assert.equal(getViewChildren(container).length, 2, 'only 2 views are rendered'); + }); +}); + +function waitForAnimation(): Promise { + return timeout(200); +} + +function getViewChildren(container: HTMLElement): NodeListOf { + return container.querySelectorAll('.scrollable-view .scrollable-view-container > .scrollable-view-child'); +} diff --git a/src/sql/workbench/contrib/notebook/browser/outputs/gridOutput.component.ts b/src/sql/workbench/contrib/notebook/browser/outputs/gridOutput.component.ts index ff925f9ddf..5f003f5f3b 100644 --- a/src/sql/workbench/contrib/notebook/browser/outputs/gridOutput.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/outputs/gridOutput.component.ts @@ -31,12 +31,10 @@ import { getErrorMessage } from 'vs/base/common/errors'; import { ISerializationService, SerializeDataParams } from 'sql/platform/serialization/common/serializationService'; import { SaveResultAction, IGridActionContext } from 'sql/workbench/contrib/query/browser/actions'; import { ResultSerializer, SaveResultsResponse, SaveFormat } from 'sql/workbench/services/query/common/resultSerializer'; -import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; import { values } from 'vs/base/common/collections'; import { assign } from 'vs/base/common/objects'; import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; import { ChartView } from 'sql/workbench/contrib/charts/browser/chartView'; -import { Orientation } from 'vs/base/browser/ui/splitview/splitview'; import { ToggleableAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; import { IInsightOptions } from 'sql/workbench/common/editor/query/chartState'; import { NotebookChangeType } from 'sql/workbench/services/notebook/common/contracts'; @@ -109,7 +107,7 @@ export class GridOutputComponent extends AngularDisposable implements IMimeCompo this._register(attachTableStyler(this._table, this.themeService)); this.layout(); - this._table.onAdd(); + this._table.onDidInsert(); this._initialized = true; } } @@ -117,7 +115,7 @@ export class GridOutputComponent extends AngularDisposable implements IMimeCompo layout(): void { if (this._table) { let maxSize = Math.min(this._table.maximumSize, 500); - this._table.layout(maxSize, undefined, ActionsOrientation.HORIZONTAL); + this._table.layout(maxSize); } } } @@ -181,8 +179,8 @@ class DataResourceTable extends GridTableBase { return Math.max(this.maxSize, /* ACTIONBAR_HEIGHT + BOTTOM_PADDING */ 0); } - public layout(size?: number, orientation?: Orientation, actionsOrientation?: ActionsOrientation): void { - super.layout(size, orientation, actionsOrientation); + public layout(size?: number): void { + super.layout(size); if (!this._chartContainer) { this._chartContainer = document.createElement('div'); diff --git a/src/sql/workbench/contrib/query/browser/gridPanel.ts b/src/sql/workbench/contrib/query/browser/gridPanel.ts index ed31d49a37..a57ad3f0ce 100644 --- a/src/sql/workbench/contrib/query/browser/gridPanel.ts +++ b/src/sql/workbench/contrib/query/browser/gridPanel.ts @@ -10,7 +10,6 @@ import QueryRunner, { QueryGridDataProvider } from 'sql/workbench/services/query import { ResultSetSummary, IColumn } from 'sql/workbench/services/query/common/query'; import { VirtualizedCollection, AsyncDataProvider } from 'sql/base/browser/ui/table/asyncDataView'; import { Table } from 'sql/base/browser/ui/table/table'; -import { ScrollableSplitView, IView } from 'sql/base/browser/ui/scrollableSplitview/scrollableSplitview'; import { MouseWheelSupport } from 'sql/base/browser/ui/table/plugins/mousewheelTableScroll.plugin'; import { AutoColumnSize } from 'sql/base/browser/ui/table/plugins/autoSizeColumns.plugin'; import { IGridActionContext, SaveResultAction, CopyResultAction, SelectAllGridAction, MaximizeTableAction, RestoreTableAction, ChartDataAction, VisualizerDataAction } from 'sql/workbench/contrib/query/browser/actions'; @@ -37,7 +36,6 @@ import { isInDOM, Dimension } from 'vs/base/browser/dom'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IAction, Separator } from 'vs/base/common/actions'; -import { ScrollbarVisibility } from 'vs/base/common/scrollable'; import { ILogService } from 'vs/platform/log/common/log'; import { localize } from 'vs/nls'; import { IGridDataProvider } from 'sql/workbench/services/query/common/gridDataProvider'; @@ -47,6 +45,7 @@ import { GridPanelState, GridTableState } from 'sql/workbench/common/editor/quer import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; import { SaveFormat } from 'sql/workbench/services/query/common/resultSerializer'; import { Progress } from 'vs/platform/progress/common/progress'; +import { ScrollableView, IView } from 'sql/base/browser/ui/scrollableView/scrollableView'; import { IQueryEditorConfiguration } from 'sql/platform/query/common/query'; const ROW_HEIGHT = 29; @@ -64,7 +63,7 @@ const MIN_GRID_HEIGHT = (MIN_GRID_HEIGHT_ROWS * ROW_HEIGHT) + HEADER_HEIGHT + ES export class GridPanel extends Disposable { private container = document.createElement('div'); - private splitView: ScrollableSplitView; + private scrollableView: ScrollableView; private tables: GridTable[] = []; private tableDisposable = this._register(new DisposableStore()); private queryRunnerDisposables = this._register(new DisposableStore()); @@ -82,10 +81,10 @@ export class GridPanel extends Disposable { @ILogService private readonly logService: ILogService ) { super(); - this.splitView = new ScrollableSplitView(this.container, { enableResizing: false, verticalScrollbarVisibility: ScrollbarVisibility.Visible }); - this.splitView.onScroll(e => { - if (this.state && this.splitView.length !== 0) { - this.state.scrollPosition = e; + this.scrollableView = new ScrollableView(this.container); + this.scrollableView.onDidScroll(e => { + if (this.state && this.scrollableView.length !== 0) { + this.state.scrollPosition = e.scrollTop; } }); } @@ -98,7 +97,7 @@ export class GridPanel extends Disposable { } public layout(size: Dimension): void { - this.splitView.layout(size.height); + this.scrollableView.layout(size.height); // if the size hasn't change it won't layout our table so we have to do it manually if (size.height === this.currentHeight) { this.tables.map(e => e.layout()); @@ -133,12 +132,12 @@ export class GridPanel extends Disposable { }, [])); if (this.state && this.state.scrollPosition) { - this.splitView.setScrollPosition(this.state.scrollPosition); + this.scrollableView.setScrollTop(this.state.scrollPosition); } } public resetScrollPosition(): void { - this.splitView.setScrollPosition(this.state.scrollPosition); + this.scrollableView.setScrollTop(this.state.scrollPosition); } private onResultSet(resultSet: ResultSetSummary | ResultSetSummary[]) { @@ -154,7 +153,7 @@ export class GridPanel extends Disposable { }); if (this.state && this.state.scrollPosition) { - this.splitView.setScrollPosition(this.state.scrollPosition); + this.scrollableView.setScrollTop(this.state.scrollPosition); } }; @@ -180,7 +179,7 @@ export class GridPanel extends Disposable { const sizeChanges = () => { if (this.state && this.state.scrollPosition) { - this.splitView.setScrollPosition(this.state.scrollPosition); + this.scrollableView.setScrollTop(this.state.scrollPosition); } }; @@ -244,7 +243,7 @@ export class GridPanel extends Disposable { } if (isUndefinedOrNull(this.maximizedGrid)) { - this.splitView.addViews(tables, tables.map(i => i.minimumSize), this.splitView.length); + this.scrollableView.addViews(tables); } } @@ -254,9 +253,7 @@ export class GridPanel extends Disposable { } private reset() { - for (let i = this.splitView.length - 1; i >= 0; i--) { - this.splitView.removeView(i); - } + this.scrollableView.clear(); dispose(this.tables); this.tableDisposable.clear(); this.tables = []; @@ -275,7 +272,7 @@ export class GridPanel extends Disposable { continue; } - this.splitView.removeView(i); + this.scrollableView.clear(); } } @@ -283,8 +280,8 @@ export class GridPanel extends Disposable { if (this.maximizedGrid) { this.maximizedGrid.state.maximized = false; this.maximizedGrid = undefined; - this.splitView.removeView(0); - this.splitView.addViews(this.tables, this.tables.map(i => i.minimumSize)); + this.scrollableView.clear(); + this.scrollableView.addViews(this.tables); } } @@ -319,6 +316,10 @@ export interface IDataSet { columnInfo: IColumn[]; } +export interface IGridTableOptions { + actionOrientation: ActionsOrientation; +} + export abstract class GridTableBase extends Disposable implements IView { private table: Table; private actionBar: ActionBar; @@ -368,7 +369,8 @@ export abstract class GridTableBase extends Disposable implements IView { protected instantiationService: IInstantiationService, protected editorService: IEditorService, protected untitledEditorService: IUntitledTextEditorService, - protected configurationService: IConfigurationService + protected configurationService: IConfigurationService, + private readonly options: IGridTableOptions = { actionOrientation: ActionsOrientation.VERTICAL } ) { super(); let config = this.configurationService.getValue<{ rowHeight: number }>('resultsGrid'); @@ -398,7 +400,10 @@ export abstract class GridTableBase extends Disposable implements IView { return this._resultSet; } - public onAdd() { + public onDidInsert() { + if (!this.table) { + this.build(); + } this.visible = true; let collection = new VirtualizedCollection( 50, @@ -414,7 +419,7 @@ export abstract class GridTableBase extends Disposable implements IView { this.setupState(); } - public onRemove() { + public onDidRemove() { this.visible = false; let collection = new VirtualizedCollection( 50, @@ -429,17 +434,11 @@ export abstract class GridTableBase extends Disposable implements IView { } // actionsOrientation controls the orientation (horizontal or vertical) of the actionBar - private build(actionsOrientation?: ActionsOrientation): void { - - // Default is VERTICAL - if (isUndefinedOrNull(actionsOrientation)) { - actionsOrientation = ActionsOrientation.VERTICAL; - } - + private build(): void { let actionBarContainer = document.createElement('div'); // Create a horizontal actionbar if orientation passed in is HORIZONTAL - if (actionsOrientation === ActionsOrientation.HORIZONTAL) { + if (this.options.actionOrientation === ActionsOrientation.HORIZONTAL) { actionBarContainer.className = 'grid-panel action-bar horizontal'; this.container.appendChild(actionBarContainer); } @@ -490,7 +489,7 @@ export abstract class GridTableBase extends Disposable implements IView { this.table.style(this.styles); } // If the actionsOrientation passed in is "VERTICAL" (or no actionsOrientation is passed in at all), create a vertical actionBar - if (actionsOrientation === ActionsOrientation.VERTICAL) { + if (this.options.actionOrientation === ActionsOrientation.VERTICAL) { actionBarContainer.className = 'grid-panel action-bar vertical'; actionBarContainer.style.width = ACTIONBAR_WIDTH + 'px'; this.container.appendChild(actionBarContainer); @@ -503,7 +502,7 @@ export abstract class GridTableBase extends Disposable implements IView { resultId: this.resultSet.id }; this.actionBar = new ActionBar(actionBarContainer, { - orientation: actionsOrientation, context: context + orientation: this.options.actionOrientation, context: context }); // update context before we run an action this.selectionModel.onSelectedRangesChanged.subscribe(e => { @@ -637,10 +636,7 @@ export abstract class GridTableBase extends Disposable implements IView { protected abstract getContextActions(): IAction[]; // The actionsOrientation passed in controls the actionBar orientation - public layout(size?: number, orientation?: Orientation, actionsOrientation?: ActionsOrientation): void { - if (!this.table) { - this.build(actionsOrientation); - } + public layout(size?: number): void { if (!size) { size = this.currentHeight; } else {