diff --git a/src/vs/base/browser/ui/splitview/splitview2.ts b/src/vs/base/browser/ui/splitview/splitview2.ts index a65ad774a40aa4545de7b28d27631d0dc4c9ce5c..3d357223596a62bd5ea7ddb3e1e6cc13e61d33f3 100644 --- a/src/vs/base/browser/ui/splitview/splitview2.ts +++ b/src/vs/base/browser/ui/splitview/splitview2.ts @@ -6,10 +6,12 @@ 'use strict'; import 'vs/css!./splitview'; -import { IDisposable, combinedDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, combinedDisposable, empty as EmptyDisposable, toDisposable } from 'vs/base/common/lifecycle'; import Event, { fromEventEmitter, mapEvent } from 'vs/base/common/event'; import types = require('vs/base/common/types'); import dom = require('vs/base/browser/dom'); +import { clamp } from 'vs/base/common/numbers'; +import { range, firstIndex, weave } from 'vs/base/common/arrays'; import { Sash, IVerticalSashLayoutProvider, IHorizontalSashLayoutProvider, Orientation, ISashEvent as IBaseSashEvent } from 'vs/base/browser/ui/sash/sash'; export { Orientation } from 'vs/base/browser/ui/sash/sash'; @@ -33,33 +35,22 @@ export interface IView { focus(): void; } -class ViewItem { - - public explicitSize: number; - - constructor( - readonly view: IView, - readonly container: HTMLElement, - public size: number, - private orientation: Orientation, - private disposables: IDisposable[] - ) { - - } - - layout(): void { - if (this.orientation === Orientation.VERTICAL) { - this.container.style.height = `${this.size}px`; - } else { - this.container.style.width = `${this.size}px`; - } +interface IViewItem { + view: IView; + size: number; + explicitSize: number; + container: HTMLElement; + disposable: IDisposable; +} - this.view.layout(this.size, this.orientation); +function layoutViewItem(item: IViewItem, orientation: Orientation): void { + if (orientation === Orientation.VERTICAL) { + item.container.style.height = `${item.size}px`; + } else { + item.container.style.width = `${item.size}px`; } - dispose(): void { - this.disposables = dispose(this.disposables); - } + item.view.layout(item.size, orientation); } interface ISashItem { @@ -67,14 +58,27 @@ interface ISashItem { disposable: IDisposable; } +interface ISashDragState { + start: number; + sizes: number[]; + up: number[]; + down: number[]; + maxUp: number; + maxDown: number; + collapses: number[]; + expands: number[]; +} + +const sum = (a: number[]) => a.reduce((a, b) => a + b, 0); + export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IVerticalSashLayoutProvider { private orientation: Orientation; private el: HTMLElement; - // private size: number; + private size = 0; // private viewElements: HTMLElement[]; - private viewItems: ViewItem[] = []; + private viewItems: IViewItem[] = []; private sashItems: ISashItem[] = []; // private viewChangeListeners: IDisposable[]; // private viewFocusPreviousListeners: IDisposable[]; @@ -86,7 +90,7 @@ export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IV // private sashesListeners: IDisposable[]; // private eventWrapper: (event: ISashEvent) => ISashEvent; // private animationTimeout: number; - // private state: IState; + private sashDragState: ISashDragState; // private _onFocus: Emitter = this._register(new Emitter()); // readonly onFocus: Event = this._onFocus.event; @@ -133,25 +137,27 @@ export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IV // this.addView(new VoidView(), 0); } - private getContainerSize(): number { - return this.orientation === Orientation.VERTICAL - ? dom.getContentHeight(this.container) - : dom.getContentWidth(this.container); - } + // private getContainerSize(): number { + // return this.orientation === Orientation.VERTICAL + // ? dom.getContentHeight(this.container) + // : dom.getContentWidth(this.container); + // } - addView(view: IView, size: number, index = this.viewItems.length - 1): void { + addView(view: IView, size: number, index = this.viewItems.length): void { // Create view container const container = document.createElement('div'); dom.addClass(container, 'split-view-view'); const containerDisposable = toDisposable(() => this.el.removeChild(container)); - // List to change events - const onChangeDisposable = mapEvent(view.onDidChange, () => item)(this.onViewChange, this); - // Create item - const item = new ViewItem(view, container, size, this.orientation, [onChangeDisposable, containerDisposable]); + const item: IViewItem = { view, container, explicitSize: size, size, disposable: EmptyDisposable }; this.viewItems.splice(index, 0, item); + const onChangeDisposable = mapEvent(view.onDidChange, () => item)(this.onViewChange, this); + + // Disposable + item.disposable = combinedDisposable([onChangeDisposable, containerDisposable]); + // Render view view.render(container, this.orientation); @@ -163,33 +169,33 @@ export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IV } // Add sash - if (this.viewItems.length <= 1) { - return; + if (this.viewItems.length > 1) { + const orientation = this.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; + const sash = new Sash(this.el, this, { orientation }); + const sashEventMapper = this.orientation === Orientation.VERTICAL + ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY }) + : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX }); + + const onStart = mapEvent(fromEventEmitter(sash, 'start'), sashEventMapper); + const onStartDisposable = onStart(this.onSashStart, this); + const onChange = mapEvent(fromEventEmitter(sash, 'change'), sashEventMapper); + const onSashChangeDisposable = onChange(this.onSashChange, this); + const disposable = combinedDisposable([onStartDisposable, onSashChangeDisposable, sash]); + const sashItem: ISashItem = { sash, disposable }; + + this.sashItems.splice(index - 1, 0, sashItem); } - const orientation = this.orientation === Orientation.VERTICAL - ? Orientation.HORIZONTAL - : Orientation.VERTICAL; - - const sash = new Sash(this.el, this, { orientation }); - const sashEventMapper = this.orientation === Orientation.VERTICAL - ? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY }) - : (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX }); + // TODO: layout + // go through all viewitems, set their size to preferred size + // sum all sizes up + // run expandcollapse - const onStart = mapEvent(fromEventEmitter(sash, 'start'), sashEventMapper); - const onStartDisposable = onStart(this.onSashStart, this); + this.viewItems.forEach(i => i.size = clamp(i.explicitSize, i.view.minimumSize, i.view.maximumSize)); - const onChange = mapEvent(fromEventEmitter(sash, 'change'), sashEventMapper); - const onSashChangeDisposable = onChange(this.onSashChange, this); - - const disposable = combinedDisposable([onStartDisposable, onSashChangeDisposable, sash]); - - const sashItem: ISashItem = { - sash, - disposable - }; - - this.sashItems.splice(index - 1, 0, sashItem); + const previousSize = this.size; + this.size = this.viewItems.reduce((r, i) => r + i.size, 0); + this.layout(previousSize); } removeView(index: number): void { @@ -199,63 +205,153 @@ export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IV // Remove view const viewItem = this.viewItems.splice(index, 1)[0]; - viewItem.dispose(); + const collapse = viewItem.size; + viewItem.disposable.dispose(); - if (this.viewItems.length < 1) { - return; + // Remove sash + if (this.viewItems.length >= 1) { + const sashIndex = Math.max(index - 1, 0); + const sashItem = this.sashItems.splice(sashIndex, 1)[0]; + sashItem.disposable.dispose(); } - // Remove sash - const sashIndex = Math.max(index - 1, 0); - const sashItem = this.sashItems.splice(sashIndex, 1)[0]; - sashItem.disposable.dispose(); + // Layout views + const up = range(index - 1, -1); + const down = range(index, this.viewItems.length); + const indexes = weave(up, down); + const collapses = this.viewItems.map(i => Math.max(i.size - i.view.minimumSize, 0)); + + this.expandCollapse(collapse, collapses, [], indexes, []); } - layout(size?: number): void { - size = size || this.getContainerSize(); + layout(size: number): void { + // size = size || this.getContainerSize(); + + // size = Math.max(size, this.viewItems.reduce((t, i) => t + i.view.minimumSize, 0)); + + + if (this.size === size) { + return; + } + + const indexes = range(this.viewItems.length - 1, -1); + const collapses = this.viewItems.map(i => Math.max(i.size - i.view.minimumSize, 0)); + const expands = this.viewItems.map(i => Math.max(i.view.maximumSize - i.size, 0)); + const diff = Math.abs(this.size - size); + + if (size < this.size) { + this.expandCollapse(Math.min(diff, sum(collapses)), collapses, expands, indexes, []); + } else if (size > this.size) { + this.expandCollapse(Math.min(diff, sum(expands)), collapses, expands, [], indexes); + } + + this.size = size; } - private onSashStart({ sash, start, current }: ISashEvent): void { + private onSashStart({ sash, start }: ISashEvent): void { + const i = firstIndex(this.sashItems, item => item.sash === sash); + const sizes = this.viewItems.map(i => i.size); + const collapses = this.viewItems.map(i => Math.max(i.size - i.view.minimumSize, 0)); + const expands = this.viewItems.map(i => Math.max(i.view.maximumSize - i.size, 0)); + + + const up = range(i, -1); + const down = range(i + 1, this.viewItems.length); + const collapsesUp = up.map(i => collapses[i]); + const collapsesDown = down.map(i => collapses[i]); + const expandsUp = up.map(i => expands[i]); + const expandsDown = down.map(i => expands[i]); + + const maxUp = Math.min(sum(collapsesUp), sum(expandsDown)); + const maxDown = Math.min(sum(expandsUp), sum(collapsesDown)); + + this.sashDragState = { start, sizes, up, down, maxUp, maxDown, collapses, expands }; } private onSashChange({ sash, start, current }: ISashEvent): void { + const diff = current - this.sashDragState.start; + + if (diff < 0) { + this.expandCollapse(Math.min(-diff, this.sashDragState.maxUp), this.sashDragState.collapses, this.sashDragState.expands, this.sashDragState.up, this.sashDragState.down); + } else { + this.expandCollapse(Math.min(diff, this.sashDragState.maxDown), this.sashDragState.collapses, this.sashDragState.expands, this.sashDragState.down, this.sashDragState.up); + } + this.viewItems.forEach(viewItem => viewItem.explicitSize = viewItem.size); } - // Main algorithm - // private expandCollapse(collapse: number, collapses: number[], expands: number[], collapseIndexes: number[], expandIndexes: number[]): void { - // let totalCollapse = collapse; - // let totalExpand = totalCollapse; - - // collapseIndexes.forEach(i => { - // let collapse = Math.min(collapses[i], totalCollapse); - // totalCollapse -= collapse; - // this.views[i].size -= collapse; - // }); - - // expandIndexes.forEach(i => { - // let expand = Math.min(expands[i], totalExpand); - // totalExpand -= expand; - // this.views[i].size += expand; - // }); - // } + private onViewChange(item: IViewItem): void { + const size = clamp(item.size, item.view.minimumSize, item.view.maximumSize); + + if (size === item.size) { + return; + } + + // this could maybe use the same code than the addView() does + + // this.setupAnimation(); + + const index = this.viewItems.indexOf(item); + const diff = Math.abs(size - item.size); + + const up = range(index - 1, -1); + const down = range(index + 1, this.viewItems.length); + const downUp = down.concat(up); + + const collapses = this.viewItems.map(i => Math.max(i.size - i.view.minimumSize, 0)); + const expands = this.viewItems.map(i => Math.max(i.view.maximumSize - i.size, 0)); - private getLastFlexibleViewIndex(exceptIndex: number = null): number { - // for (let i = this.views.length - 1; i >= 0; i--) { - // if (exceptIndex === i) { - // continue; - // } - // if (this.views[i].sizing === ViewSizing.Flexible) { - // return i; - // } - // } - return -1; + let collapse: number, collapseIndexes: number[], expandIndexes: number[]; + + if (size < item.size) { + collapse = Math.min(downUp.reduce((t, i) => t + expands[i], 0), diff); + collapseIndexes = [index]; + expandIndexes = downUp; + + } else { + collapse = Math.min(downUp.reduce((t, i) => t + collapses[i], 0), diff); + collapseIndexes = downUp; + expandIndexes = [index]; + } + + this.expandCollapse(collapse, collapses, expands, collapseIndexes, expandIndexes); + // this.layoutViews(); } - private layoutViews(): void { - this.viewItems.forEach(item => item.layout()); + // private setupAnimation(): void { + // if (types.isNumber(this.animationTimeout)) { + // window.clearTimeout(this.animationTimeout); + // } + + // dom.addClass(this.el, 'animated'); + // this.animationTimeout = window.setTimeout(() => this.clearAnimation(), 200); + // } + + // private clearAnimation(): void { + // this.animationTimeout = null; + // dom.removeClass(this.el, 'animated'); + // } + + // Main algorithm + private expandCollapse(collapse: number, collapses: number[], expands: number[], collapseIndexes: number[], expandIndexes: number[]): void { + let totalCollapse = collapse; + let totalExpand = totalCollapse; + + collapseIndexes.forEach(i => { + let collapse = Math.min(collapses[i], totalCollapse); + totalCollapse -= collapse; + this.viewItems[i].size -= collapse; + }); + + expandIndexes.forEach(i => { + let expand = Math.min(expands[i], totalExpand); + totalExpand -= expand; + this.viewItems[i].size += expand; + }); + + this.viewItems.forEach(item => layoutViewItem(item, this.orientation)); this.sashItems.forEach(item => item.sash.layout()); // Update sashes enablement @@ -281,23 +377,6 @@ export class SplitView implements IDisposable, IHorizontalSashLayoutProvider, IV // }); } - private onViewChange(view: ViewItem): void { - } - - // private setupAnimation(): void { - // if (types.isNumber(this.animationTimeout)) { - // window.clearTimeout(this.animationTimeout); - // } - - // dom.addClass(this.el, 'animated'); - // this.animationTimeout = window.setTimeout(() => this.clearAnimation(), 200); - // } - - // private clearAnimation(): void { - // this.animationTimeout = null; - // dom.removeClass(this.el, 'animated'); - // } - getVerticalSashLeft(sash: Sash): number { return this.getSashPosition(sash); } diff --git a/src/vs/base/common/numbers.ts b/src/vs/base/common/numbers.ts index 65bcbbd79229445d5b414984568db7dc78f0690f..9f804fe6dd9ccffb4a557cfbe87330fe0790906c 100644 --- a/src/vs/base/common/numbers.ts +++ b/src/vs/base/common/numbers.ts @@ -46,3 +46,8 @@ export function countToArray(fromOrTo: number, to?: number): number[] { return result; } + + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} \ No newline at end of file diff --git a/src/vs/base/test/browser/ui/splitview/splitview.test.ts b/src/vs/base/test/browser/ui/splitview/splitview.test.ts index 94dbb16fe8780982fb93f03a3deea5c2eaf21d46..60d531b6c41c371dd50092611d4d21e9ebe91cce 100644 --- a/src/vs/base/test/browser/ui/splitview/splitview.test.ts +++ b/src/vs/base/test/browser/ui/splitview/splitview.test.ts @@ -21,6 +21,8 @@ class TestView implements IView { private _onDidRender = new Emitter<{ container: HTMLElement; orientation: Orientation }>(); readonly onDidRender = this._onDidRender.event; + private _size = 0; + get size(): number { return this._size; } private _onDidLayout = new Emitter<{ size: number; orientation: Orientation }>(); readonly onDidLayout = this._onDidLayout.event; @@ -39,6 +41,7 @@ class TestView implements IView { } layout(size: number, orientation: Orientation): void { + this._size = size; this._onDidLayout.fire({ size, orientation }); } @@ -54,14 +57,16 @@ class TestView implements IView { } } +const TOTAL_SIZE = 200; + suite('Splitview', () => { let container: HTMLElement; setup(() => { container = document.createElement('div'); container.style.position = 'absolute'; - container.style.width = '200px'; - container.style.height = '200px'; + container.style.width = `${TOTAL_SIZE}px`; + container.style.height = `${TOTAL_SIZE}px`; }); teardown(() => { @@ -75,12 +80,14 @@ suite('Splitview', () => { }); test('splitview has views as sashes as children', () => { - const view = new TestView(20, 20); + const view1 = new TestView(20, 20); + const view2 = new TestView(20, 20); + const view3 = new TestView(20, 20); const splitview = new SplitView(container); - splitview.addView(view, 20); - splitview.addView(view, 20); - splitview.addView(view, 20); + splitview.addView(view1, 20); + splitview.addView(view2, 20); + splitview.addView(view3, 20); let viewQuery = container.querySelectorAll('.monaco-split-view > .split-view-view'); assert.equal(viewQuery.length, 3, 'split view should have 3 views'); @@ -113,6 +120,67 @@ suite('Splitview', () => { assert.equal(sashQuery.length, 0, 'split view should have no sashes'); splitview.dispose(); + view1.dispose(); + view2.dispose(); + view3.dispose(); + }); + + test('splitview calls view methods on addView and removeView', () => { + const view = new TestView(20, 20); + const splitview = new SplitView(container); + + let didLayout = false; + const layoutDisposable = view.onDidLayout(() => didLayout = true); + + let didRender = false; + const renderDisposable = view.onDidRender(() => didRender = true); + + splitview.addView(view, 20); + + assert.equal(view.size, 20, 'view has right size'); + assert(didLayout, 'layout was called'); + assert(didLayout, 'render was called'); + + splitview.dispose(); + layoutDisposable.dispose(); + renderDisposable.dispose(); view.dispose(); }); + + test('splitview stretches view to viewport', () => { + const view = new TestView(20, Number.POSITIVE_INFINITY); + const splitview = new SplitView(container); + splitview.layout(TOTAL_SIZE); + + splitview.addView(view, 20); + assert.equal(view.size, TOTAL_SIZE, 'view was stretched'); + + splitview.dispose(); + view.dispose(); + }); + + test('splitview respects preferred sizes', () => { + 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 splitview = new SplitView(container); + splitview.layout(TOTAL_SIZE); + + splitview.addView(view1, 20); + assert.equal(view1.size, TOTAL_SIZE, 'view1 was stretched'); + + splitview.addView(view2, 20); + assert.equal(view1.size, 20, 'view1 size was restored'); + assert.equal(view2.size, TOTAL_SIZE - 20, 'view2 was stretched'); + + splitview.addView(view3, 20); + assert.equal(view1.size, 20, 'view1 size was restored'); + assert.equal(view2.size, 20, 'view2 size was restored'); + assert.equal(view3.size, TOTAL_SIZE - 20 * 2, 'view3 was stretched'); + + splitview.dispose(); + view3.dispose(); + view2.dispose(); + view1.dispose(); + }); }); \ No newline at end of file