Path: blob/main/src/vs/base/browser/ui/splitview/splitview.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { $, addDisposableListener, append, getWindow, scheduleAtNextAnimationFrame } from '../../dom.js';6import { DomEmitter } from '../../event.js';7import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js';8import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js';9import { pushToEnd, pushToStart, range } from '../../../common/arrays.js';10import { Color } from '../../../common/color.js';11import { Emitter, Event } from '../../../common/event.js';12import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js';13import { clamp } from '../../../common/numbers.js';14import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js';15import * as types from '../../../common/types.js';16import './splitview.css';17export { Orientation } from '../sash/sash.js';1819export interface ISplitViewStyles {20readonly separatorBorder: Color;21}2223const defaultStyles: ISplitViewStyles = {24separatorBorder: Color.transparent25};2627export const enum LayoutPriority {28Normal,29Low,30High31}3233/**34* The interface to implement for views within a {@link SplitView}.35*36* An optional {@link TLayoutContext layout context type} may be used in order to37* pass along layout contextual data from the {@link SplitView.layout} method down38* to each view's {@link IView.layout} calls.39*/40export interface IView<TLayoutContext = undefined> {4142/**43* The DOM element for this view.44*/45readonly element: HTMLElement;4647/**48* A minimum size for this view.49*50* @remarks If none, set it to `0`.51*/52readonly minimumSize: number;5354/**55* A maximum size for this view.56*57* @remarks If none, set it to `Number.POSITIVE_INFINITY`.58*/59readonly maximumSize: number;6061/**62* The priority of the view when the {@link SplitView.resize layout} algorithm63* runs. Views with higher priority will be resized first.64*65* @remarks Only used when `proportionalLayout` is false.66*/67readonly priority?: LayoutPriority;6869/**70* If the {@link SplitView} supports {@link ISplitViewOptions.proportionalLayout proportional layout},71* this property allows for finer control over the proportional layout algorithm, per view.72*73* @defaultValue `true`74*/75readonly proportionalLayout?: boolean;7677/**78* Whether the view will snap whenever the user reaches its minimum size or79* attempts to grow it beyond the minimum size.80*81* @defaultValue `false`82*/83readonly snap?: boolean;8485/**86* View instances are supposed to fire the {@link IView.onDidChange} event whenever87* any of the constraint properties have changed:88*89* - {@link IView.minimumSize}90* - {@link IView.maximumSize}91* - {@link IView.priority}92* - {@link IView.snap}93*94* The SplitView will relayout whenever that happens. The event can optionally emit95* the view's preferred size for that relayout.96*/97readonly onDidChange: Event<number | undefined>;9899/**100* This will be called by the {@link SplitView} during layout. A view meant to101* pass along the layout information down to its descendants.102*103* @param size The size of this view, in pixels.104* @param offset The offset of this view, relative to the start of the {@link SplitView}.105* @param context The optional {@link IView layout context} passed to {@link SplitView.layout}.106*/107layout(size: number, offset: number, context: TLayoutContext | undefined): void;108109/**110* This will be called by the {@link SplitView} whenever this view is made111* visible or hidden.112*113* @param visible Whether the view becomes visible.114*/115setVisible?(visible: boolean): void;116}117118/**119* A descriptor for a {@link SplitView} instance.120*/121export interface ISplitViewDescriptor<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {122123/**124* The layout size of the {@link SplitView}.125*/126readonly size: number;127128/**129* Descriptors for each {@link IView view}.130*/131readonly views: {132133/**134* Whether the {@link IView view} is visible.135*136* @defaultValue `true`137*/138readonly visible?: boolean;139140/**141* The size of the {@link IView view}.142*143* @defaultValue `true`144*/145readonly size: number;146147/**148* The size of the {@link IView view}.149*150* @defaultValue `true`151*/152readonly view: TView;153}[];154}155156export interface ISplitViewOptions<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {157158/**159* Which axis the views align on.160*161* @defaultValue `Orientation.VERTICAL`162*/163readonly orientation?: Orientation;164165/**166* Styles overriding the {@link defaultStyles default ones}.167*/168readonly styles?: ISplitViewStyles;169170/**171* Make Alt-drag the default drag operation.172*/173readonly inverseAltBehavior?: boolean;174175/**176* Resize each view proportionally when resizing the SplitView.177*178* @defaultValue `true`179*/180readonly proportionalLayout?: boolean;181182/**183* An initial description of this {@link SplitView} instance, allowing184* to initialze all views within the ctor.185*/186readonly descriptor?: ISplitViewDescriptor<TLayoutContext, TView>;187188/**189* The scrollbar visibility setting for whenever the views within190* the {@link SplitView} overflow.191*/192readonly scrollbarVisibility?: ScrollbarVisibility;193194/**195* Override the orthogonal size of sashes.196*/197readonly getSashOrthogonalSize?: () => number;198}199200interface ISashEvent {201readonly sash: Sash;202readonly start: number;203readonly current: number;204readonly alt: boolean;205}206207type ViewItemSize = number | { cachedVisibleSize: number };208209abstract class ViewItem<TLayoutContext, TView extends IView<TLayoutContext>> {210211private _size: number;212set size(size: number) {213this._size = size;214}215216get size(): number {217return this._size;218}219220private _cachedVisibleSize: number | undefined = undefined;221get cachedVisibleSize(): number | undefined { return this._cachedVisibleSize; }222223get visible(): boolean {224return typeof this._cachedVisibleSize === 'undefined';225}226227setVisible(visible: boolean, size?: number): void {228if (visible === this.visible) {229return;230}231232if (visible) {233this.size = clamp(this._cachedVisibleSize!, this.viewMinimumSize, this.viewMaximumSize);234this._cachedVisibleSize = undefined;235} else {236this._cachedVisibleSize = typeof size === 'number' ? size : this.size;237this.size = 0;238}239240this.container.classList.toggle('visible', visible);241242try {243this.view.setVisible?.(visible);244} catch (e) {245console.error('Splitview: Failed to set visible view');246console.error(e);247}248}249250get minimumSize(): number { return this.visible ? this.view.minimumSize : 0; }251get viewMinimumSize(): number { return this.view.minimumSize; }252253get maximumSize(): number { return this.visible ? this.view.maximumSize : 0; }254get viewMaximumSize(): number { return this.view.maximumSize; }255256get priority(): LayoutPriority | undefined { return this.view.priority; }257get proportionalLayout(): boolean { return this.view.proportionalLayout ?? true; }258get snap(): boolean { return !!this.view.snap; }259260set enabled(enabled: boolean) {261this.container.style.pointerEvents = enabled ? '' : 'none';262}263264constructor(265protected container: HTMLElement,266readonly view: TView,267size: ViewItemSize,268private disposable: IDisposable269) {270if (typeof size === 'number') {271this._size = size;272this._cachedVisibleSize = undefined;273container.classList.add('visible');274} else {275this._size = 0;276this._cachedVisibleSize = size.cachedVisibleSize;277}278}279280layout(offset: number, layoutContext: TLayoutContext | undefined): void {281this.layoutContainer(offset);282283try {284this.view.layout(this.size, offset, layoutContext);285} catch (e) {286console.error('Splitview: Failed to layout view');287console.error(e);288}289}290291abstract layoutContainer(offset: number): void;292293dispose(): void {294this.disposable.dispose();295}296}297298class VerticalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {299300layoutContainer(offset: number): void {301this.container.style.top = `${offset}px`;302this.container.style.height = `${this.size}px`;303}304}305306class HorizontalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {307308layoutContainer(offset: number): void {309this.container.style.left = `${offset}px`;310this.container.style.width = `${this.size}px`;311}312}313314interface ISashItem {315sash: Sash;316disposable: IDisposable;317}318319interface ISashDragSnapState {320readonly index: number;321readonly limitDelta: number;322readonly size: number;323}324325interface ISashDragState {326index: number;327start: number;328current: number;329sizes: number[];330minDelta: number;331maxDelta: number;332alt: boolean;333snapBefore: ISashDragSnapState | undefined;334snapAfter: ISashDragSnapState | undefined;335disposable: IDisposable;336}337338enum State {339Idle,340Busy341}342343/**344* When adding or removing views, uniformly distribute the entire split view space among345* all views.346*/347export type DistributeSizing = { type: 'distribute' };348349/**350* When adding a view, make space for it by reducing the size of another view,351* indexed by the provided `index`.352*/353export type SplitSizing = { type: 'split'; index: number };354355/**356* When adding a view, use DistributeSizing when all pre-existing views are357* distributed evenly, otherwise use SplitSizing.358*/359export type AutoSizing = { type: 'auto'; index: number };360361/**362* When adding or removing views, assume the view is invisible.363*/364export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number };365366/**367* When adding or removing views, the sizing provides fine grained368* control over how other views get resized.369*/370export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing;371372export namespace Sizing {373374/**375* When adding or removing views, distribute the delta space among376* all other views.377*/378export const Distribute: DistributeSizing = { type: 'distribute' };379380/**381* When adding or removing views, split the delta space with another382* specific view, indexed by the provided `index`.383*/384export function Split(index: number): SplitSizing { return { type: 'split', index }; }385386/**387* When adding a view, use DistributeSizing when all pre-existing views are388* distributed evenly, otherwise use SplitSizing.389*/390export function Auto(index: number): AutoSizing { return { type: 'auto', index }; }391392/**393* When adding or removing views, assume the view is invisible.394*/395export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; }396}397398/**399* The {@link SplitView} is the UI component which implements a one dimensional400* flex-like layout algorithm for a collection of {@link IView} instances, which401* are essentially HTMLElement instances with the following size constraints:402*403* - {@link IView.minimumSize}404* - {@link IView.maximumSize}405* - {@link IView.priority}406* - {@link IView.snap}407*408* In case the SplitView doesn't have enough size to fit all views, it will overflow409* its content with a scrollbar.410*411* In between each pair of views there will be a {@link Sash} allowing the user412* to resize the views, making sure the constraints are respected.413*414* An optional {@link TLayoutContext layout context type} may be used in order to415* pass along layout contextual data from the {@link SplitView.layout} method down416* to each view's {@link IView.layout} calls.417*418* Features:419* - Flex-like layout algorithm420* - Snap support421* - Orthogonal sash support, for corner sashes422* - View hide/show support423* - View swap/move support424* - Alt key modifier behavior, macOS style425*/426export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> extends Disposable {427428/**429* This {@link SplitView}'s orientation.430*/431readonly orientation: Orientation;432433/**434* The DOM element representing this {@link SplitView}.435*/436readonly el: HTMLElement;437438private sashContainer: HTMLElement;439private viewContainer: HTMLElement;440private scrollable: Scrollable;441private scrollableElement: SmoothScrollableElement;442private size = 0;443private layoutContext: TLayoutContext | undefined;444private _contentSize = 0;445private proportions: (number | undefined)[] | undefined = undefined;446private viewItems: ViewItem<TLayoutContext, TView>[] = [];447sashItems: ISashItem[] = []; // used in tests448private sashDragState: ISashDragState | undefined;449private state: State = State.Idle;450private inverseAltBehavior: boolean;451private proportionalLayout: boolean;452private readonly getSashOrthogonalSize: { (): number } | undefined;453454private _onDidSashChange = this._register(new Emitter<number>());455private _onDidSashReset = this._register(new Emitter<number>());456private _orthogonalStartSash: Sash | undefined;457private _orthogonalEndSash: Sash | undefined;458private _startSnappingEnabled = true;459private _endSnappingEnabled = true;460461/**462* The sum of all views' sizes.463*/464get contentSize(): number { return this._contentSize; }465466/**467* Fires whenever the user resizes a {@link Sash sash}.468*/469readonly onDidSashChange = this._onDidSashChange.event;470471/**472* Fires whenever the user double clicks a {@link Sash sash}.473*/474readonly onDidSashReset = this._onDidSashReset.event;475476/**477* Fires whenever the split view is scrolled.478*/479readonly onDidScroll: Event<ScrollEvent>;480481/**482* The amount of views in this {@link SplitView}.483*/484get length(): number {485return this.viewItems.length;486}487488/**489* The minimum size of this {@link SplitView}.490*/491get minimumSize(): number {492return this.viewItems.reduce((r, item) => r + item.minimumSize, 0);493}494495/**496* The maximum size of this {@link SplitView}.497*/498get maximumSize(): number {499return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.maximumSize, 0);500}501502get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; }503get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; }504get startSnappingEnabled(): boolean { return this._startSnappingEnabled; }505get endSnappingEnabled(): boolean { return this._endSnappingEnabled; }506507/**508* A reference to a sash, perpendicular to all sashes in this {@link SplitView},509* located at the left- or top-most side of the SplitView.510* Corner sashes will be created automatically at the intersections.511*/512set orthogonalStartSash(sash: Sash | undefined) {513for (const sashItem of this.sashItems) {514sashItem.sash.orthogonalStartSash = sash;515}516517this._orthogonalStartSash = sash;518}519520/**521* A reference to a sash, perpendicular to all sashes in this {@link SplitView},522* located at the right- or bottom-most side of the SplitView.523* Corner sashes will be created automatically at the intersections.524*/525set orthogonalEndSash(sash: Sash | undefined) {526for (const sashItem of this.sashItems) {527sashItem.sash.orthogonalEndSash = sash;528}529530this._orthogonalEndSash = sash;531}532533/**534* The internal sashes within this {@link SplitView}.535*/536get sashes(): readonly Sash[] {537return this.sashItems.map(s => s.sash);538}539540/**541* Enable/disable snapping at the beginning of this {@link SplitView}.542*/543set startSnappingEnabled(startSnappingEnabled: boolean) {544if (this._startSnappingEnabled === startSnappingEnabled) {545return;546}547548this._startSnappingEnabled = startSnappingEnabled;549this.updateSashEnablement();550}551552/**553* Enable/disable snapping at the end of this {@link SplitView}.554*/555set endSnappingEnabled(endSnappingEnabled: boolean) {556if (this._endSnappingEnabled === endSnappingEnabled) {557return;558}559560this._endSnappingEnabled = endSnappingEnabled;561this.updateSashEnablement();562}563564/**565* Create a new {@link SplitView} instance.566*/567constructor(container: HTMLElement, options: ISplitViewOptions<TLayoutContext, TView> = {}) {568super();569570this.orientation = options.orientation ?? Orientation.VERTICAL;571this.inverseAltBehavior = options.inverseAltBehavior ?? false;572this.proportionalLayout = options.proportionalLayout ?? true;573this.getSashOrthogonalSize = options.getSashOrthogonalSize;574575this.el = document.createElement('div');576this.el.classList.add('monaco-split-view2');577this.el.classList.add(this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal');578container.appendChild(this.el);579580this.sashContainer = append(this.el, $('.sash-container'));581this.viewContainer = $('.split-view-container');582583this.scrollable = this._register(new Scrollable({584forceIntegerValues: true,585smoothScrollDuration: 125,586scheduleAtNextAnimationFrame: callback => scheduleAtNextAnimationFrame(getWindow(this.el), callback),587}));588this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, {589vertical: this.orientation === Orientation.VERTICAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden,590horizontal: this.orientation === Orientation.HORIZONTAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden591}, this.scrollable));592593// https://github.com/microsoft/vscode/issues/157737594const onDidScrollViewContainer = this._register(new DomEmitter(this.viewContainer, 'scroll')).event;595this._register(onDidScrollViewContainer(_ => {596const position = this.scrollableElement.getScrollPosition();597const scrollLeft = Math.abs(this.viewContainer.scrollLeft - position.scrollLeft) <= 1 ? undefined : this.viewContainer.scrollLeft;598const scrollTop = Math.abs(this.viewContainer.scrollTop - position.scrollTop) <= 1 ? undefined : this.viewContainer.scrollTop;599600if (scrollLeft !== undefined || scrollTop !== undefined) {601this.scrollableElement.setScrollPosition({ scrollLeft, scrollTop });602}603}));604605this.onDidScroll = this.scrollableElement.onScroll;606this._register(this.onDidScroll(e => {607if (e.scrollTopChanged) {608this.viewContainer.scrollTop = e.scrollTop;609}610611if (e.scrollLeftChanged) {612this.viewContainer.scrollLeft = e.scrollLeft;613}614}));615616append(this.el, this.scrollableElement.getDomNode());617618this.style(options.styles || defaultStyles);619620// We have an existing set of view, add them now621if (options.descriptor) {622this.size = options.descriptor.size;623options.descriptor.views.forEach((viewDescriptor, index) => {624const sizing = types.isUndefined(viewDescriptor.visible) || viewDescriptor.visible ? viewDescriptor.size : { type: 'invisible', cachedVisibleSize: viewDescriptor.size } satisfies InvisibleSizing;625626const view = viewDescriptor.view;627this.doAddView(view, sizing, index, true);628});629630// Initialize content size and proportions for first layout631this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);632this.saveProportions();633}634}635636style(styles: ISplitViewStyles): void {637if (styles.separatorBorder.isTransparent()) {638this.el.classList.remove('separator-border');639this.el.style.removeProperty('--separator-border');640} else {641this.el.classList.add('separator-border');642this.el.style.setProperty('--separator-border', styles.separatorBorder.toString());643}644}645646/**647* Add a {@link IView view} to this {@link SplitView}.648*649* @param view The view to add.650* @param size Either a fixed size, or a dynamic {@link Sizing} strategy.651* @param index The index to insert the view on.652* @param skipLayout Whether layout should be skipped.653*/654addView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {655this.doAddView(view, size, index, skipLayout);656}657658/**659* Remove a {@link IView view} from this {@link SplitView}.660*661* @param index The index where the {@link IView view} is located.662* @param sizing Whether to distribute other {@link IView view}'s sizes.663*/664removeView(index: number, sizing?: Sizing): TView {665if (index < 0 || index >= this.viewItems.length) {666throw new Error('Index out of bounds');667}668669if (this.state !== State.Idle) {670throw new Error('Cant modify splitview');671}672673this.state = State.Busy;674675try {676if (sizing?.type === 'auto') {677if (this.areViewsDistributed()) {678sizing = { type: 'distribute' };679} else {680sizing = { type: 'split', index: sizing.index };681}682}683684// Save referene view, in case of `split` sizing685const referenceViewItem = sizing?.type === 'split' ? this.viewItems[sizing.index] : undefined;686687// Remove view688const viewItemToRemove = this.viewItems.splice(index, 1)[0];689690// Resize reference view, in case of `split` sizing691if (referenceViewItem) {692referenceViewItem.size += viewItemToRemove.size;693}694695// Remove sash696if (this.viewItems.length >= 1) {697const sashIndex = Math.max(index - 1, 0);698const sashItem = this.sashItems.splice(sashIndex, 1)[0];699sashItem.disposable.dispose();700}701702this.relayout();703704if (sizing?.type === 'distribute') {705this.distributeViewSizes();706}707708const result = viewItemToRemove.view;709viewItemToRemove.dispose();710return result;711712} finally {713this.state = State.Idle;714}715}716717removeAllViews(): TView[] {718if (this.state !== State.Idle) {719throw new Error('Cant modify splitview');720}721722this.state = State.Busy;723724try {725const viewItems = this.viewItems.splice(0, this.viewItems.length);726727for (const viewItem of viewItems) {728viewItem.dispose();729}730731const sashItems = this.sashItems.splice(0, this.sashItems.length);732733for (const sashItem of sashItems) {734sashItem.disposable.dispose();735}736737this.relayout();738return viewItems.map(i => i.view);739740} finally {741this.state = State.Idle;742}743}744745/**746* Move a {@link IView view} to a different index.747*748* @param from The source index.749* @param to The target index.750*/751moveView(from: number, to: number): void {752if (this.state !== State.Idle) {753throw new Error('Cant modify splitview');754}755756const cachedVisibleSize = this.getViewCachedVisibleSize(from);757const sizing = typeof cachedVisibleSize === 'undefined' ? this.getViewSize(from) : Sizing.Invisible(cachedVisibleSize);758const view = this.removeView(from);759this.addView(view, sizing, to);760}761762763/**764* Swap two {@link IView views}.765*766* @param from The source index.767* @param to The target index.768*/769swapViews(from: number, to: number): void {770if (this.state !== State.Idle) {771throw new Error('Cant modify splitview');772}773774if (from > to) {775return this.swapViews(to, from);776}777778const fromSize = this.getViewSize(from);779const toSize = this.getViewSize(to);780const toView = this.removeView(to);781const fromView = this.removeView(from);782783this.addView(toView, fromSize, from);784this.addView(fromView, toSize, to);785}786787/**788* Returns whether the {@link IView view} is visible.789*790* @param index The {@link IView view} index.791*/792isViewVisible(index: number): boolean {793if (index < 0 || index >= this.viewItems.length) {794throw new Error('Index out of bounds');795}796797const viewItem = this.viewItems[index];798return viewItem.visible;799}800801/**802* Set a {@link IView view}'s visibility.803*804* @param index The {@link IView view} index.805* @param visible Whether the {@link IView view} should be visible.806*/807setViewVisible(index: number, visible: boolean): void {808if (index < 0 || index >= this.viewItems.length) {809throw new Error('Index out of bounds');810}811812const viewItem = this.viewItems[index];813viewItem.setVisible(visible);814815this.distributeEmptySpace(index);816this.layoutViews();817this.saveProportions();818}819820/**821* Returns the {@link IView view}'s size previously to being hidden.822*823* @param index The {@link IView view} index.824*/825getViewCachedVisibleSize(index: number): number | undefined {826if (index < 0 || index >= this.viewItems.length) {827throw new Error('Index out of bounds');828}829830const viewItem = this.viewItems[index];831return viewItem.cachedVisibleSize;832}833834/**835* Layout the {@link SplitView}.836*837* @param size The entire size of the {@link SplitView}.838* @param layoutContext An optional layout context to pass along to {@link IView views}.839*/840layout(size: number, layoutContext?: TLayoutContext): void {841const previousSize = Math.max(this.size, this._contentSize);842this.size = size;843this.layoutContext = layoutContext;844845if (!this.proportions) {846const indexes = range(this.viewItems.length);847const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);848const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);849850this.resize(this.viewItems.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes);851} else {852let total = 0;853854for (let i = 0; i < this.viewItems.length; i++) {855const item = this.viewItems[i];856const proportion = this.proportions[i];857858if (typeof proportion === 'number') {859total += proportion;860} else {861size -= item.size;862}863}864865for (let i = 0; i < this.viewItems.length; i++) {866const item = this.viewItems[i];867const proportion = this.proportions[i];868869if (typeof proportion === 'number' && total > 0) {870item.size = clamp(Math.round(proportion * size / total), item.minimumSize, item.maximumSize);871}872}873}874875this.distributeEmptySpace();876this.layoutViews();877}878879private saveProportions(): void {880if (this.proportionalLayout && this._contentSize > 0) {881this.proportions = this.viewItems.map(v => v.proportionalLayout && v.visible ? v.size / this._contentSize : undefined);882}883}884885private onSashStart({ sash, start, alt }: ISashEvent): void {886for (const item of this.viewItems) {887item.enabled = false;888}889890const index = this.sashItems.findIndex(item => item.sash === sash);891892// This way, we can press Alt while we resize a sash, macOS style!893const disposable = combinedDisposable(894addDisposableListener(this.el.ownerDocument.body, 'keydown', e => resetSashDragState(this.sashDragState!.current, e.altKey)),895addDisposableListener(this.el.ownerDocument.body, 'keyup', () => resetSashDragState(this.sashDragState!.current, false))896);897898const resetSashDragState = (start: number, alt: boolean) => {899const sizes = this.viewItems.map(i => i.size);900let minDelta = Number.NEGATIVE_INFINITY;901let maxDelta = Number.POSITIVE_INFINITY;902903if (this.inverseAltBehavior) {904alt = !alt;905}906907if (alt) {908// When we're using the last sash with Alt, we're resizing909// the view to the left/up, instead of right/down as usual910// Thus, we must do the inverse of the usual911const isLastSash = index === this.sashItems.length - 1;912913if (isLastSash) {914const viewItem = this.viewItems[index];915minDelta = (viewItem.minimumSize - viewItem.size) / 2;916maxDelta = (viewItem.maximumSize - viewItem.size) / 2;917} else {918const viewItem = this.viewItems[index + 1];919minDelta = (viewItem.size - viewItem.maximumSize) / 2;920maxDelta = (viewItem.size - viewItem.minimumSize) / 2;921}922}923924let snapBefore: ISashDragSnapState | undefined;925let snapAfter: ISashDragSnapState | undefined;926927if (!alt) {928const upIndexes = range(index, -1);929const downIndexes = range(index + 1, this.viewItems.length);930const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0);931const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].viewMaximumSize - sizes[i]), 0);932const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);933const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].viewMaximumSize), 0);934const minDelta = Math.max(minDeltaUp, minDeltaDown);935const maxDelta = Math.min(maxDeltaDown, maxDeltaUp);936const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);937const snapAfterIndex = this.findFirstSnapIndex(downIndexes);938939if (typeof snapBeforeIndex === 'number') {940const viewItem = this.viewItems[snapBeforeIndex];941const halfSize = Math.floor(viewItem.viewMinimumSize / 2);942943snapBefore = {944index: snapBeforeIndex,945limitDelta: viewItem.visible ? minDelta - halfSize : minDelta + halfSize,946size: viewItem.size947};948}949950if (typeof snapAfterIndex === 'number') {951const viewItem = this.viewItems[snapAfterIndex];952const halfSize = Math.floor(viewItem.viewMinimumSize / 2);953954snapAfter = {955index: snapAfterIndex,956limitDelta: viewItem.visible ? maxDelta + halfSize : maxDelta - halfSize,957size: viewItem.size958};959}960}961962this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, snapBefore, snapAfter, disposable };963};964965resetSashDragState(start, alt);966}967968private onSashChange({ current }: ISashEvent): void {969const { index, start, sizes, alt, minDelta, maxDelta, snapBefore, snapAfter } = this.sashDragState!;970this.sashDragState!.current = current;971972const delta = current - start;973const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter);974975if (alt) {976const isLastSash = index === this.sashItems.length - 1;977const newSizes = this.viewItems.map(i => i.size);978const viewItemIndex = isLastSash ? index : index + 1;979const viewItem = this.viewItems[viewItemIndex];980const newMinDelta = viewItem.size - viewItem.maximumSize;981const newMaxDelta = viewItem.size - viewItem.minimumSize;982const resizeIndex = isLastSash ? index - 1 : index + 1;983984this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta);985}986987this.distributeEmptySpace();988this.layoutViews();989}990991private onSashEnd(index: number): void {992this._onDidSashChange.fire(index);993this.sashDragState!.disposable.dispose();994this.saveProportions();995996for (const item of this.viewItems) {997item.enabled = true;998}999}10001001private onViewChange(item: ViewItem<TLayoutContext, TView>, size: number | undefined): void {1002const index = this.viewItems.indexOf(item);10031004if (index < 0 || index >= this.viewItems.length) {1005return;1006}10071008size = typeof size === 'number' ? size : item.size;1009size = clamp(size, item.minimumSize, item.maximumSize);10101011if (this.inverseAltBehavior && index > 0) {1012// In this case, we want the view to grow or shrink both sides equally1013// so we just resize the "left" side by half and let `resize` do the clamping magic1014this.resize(index - 1, Math.floor((item.size - size) / 2));1015this.distributeEmptySpace();1016this.layoutViews();1017} else {1018item.size = size;1019this.relayout([index], undefined);1020}1021}10221023/**1024* Resize a {@link IView view} within the {@link SplitView}.1025*1026* @param index The {@link IView view} index.1027* @param size The {@link IView view} size.1028*/1029resizeView(index: number, size: number): void {1030if (index < 0 || index >= this.viewItems.length) {1031return;1032}10331034if (this.state !== State.Idle) {1035throw new Error('Cant modify splitview');1036}10371038this.state = State.Busy;10391040try {1041const indexes = range(this.viewItems.length).filter(i => i !== index);1042const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index];1043const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);10441045const item = this.viewItems[index];1046size = Math.round(size);1047size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this.size));10481049item.size = size;1050this.relayout(lowPriorityIndexes, highPriorityIndexes);1051} finally {1052this.state = State.Idle;1053}1054}10551056/**1057* Returns whether all other {@link IView views} are at their minimum size.1058*/1059isViewExpanded(index: number): boolean {1060if (index < 0 || index >= this.viewItems.length) {1061return false;1062}10631064for (const item of this.viewItems) {1065if (item !== this.viewItems[index] && item.size > item.minimumSize) {1066return false;1067}1068}10691070return true;1071}10721073/**1074* Distribute the entire {@link SplitView} size among all {@link IView views}.1075*/1076distributeViewSizes(): void {1077const flexibleViewItems: ViewItem<TLayoutContext, TView>[] = [];1078let flexibleSize = 0;10791080for (const item of this.viewItems) {1081if (item.maximumSize - item.minimumSize > 0) {1082flexibleViewItems.push(item);1083flexibleSize += item.size;1084}1085}10861087const size = Math.floor(flexibleSize / flexibleViewItems.length);10881089for (const item of flexibleViewItems) {1090item.size = clamp(size, item.minimumSize, item.maximumSize);1091}10921093const indexes = range(this.viewItems.length);1094const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);1095const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);10961097this.relayout(lowPriorityIndexes, highPriorityIndexes);1098}10991100/**1101* Returns the size of a {@link IView view}.1102*/1103getViewSize(index: number): number {1104if (index < 0 || index >= this.viewItems.length) {1105return -1;1106}11071108return this.viewItems[index].size;1109}11101111private doAddView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {1112if (this.state !== State.Idle) {1113throw new Error('Cant modify splitview');1114}11151116this.state = State.Busy;11171118try {1119// Add view1120const container = $('.split-view-view');11211122if (index === this.viewItems.length) {1123this.viewContainer.appendChild(container);1124} else {1125this.viewContainer.insertBefore(container, this.viewContainer.children.item(index));1126}11271128const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size));1129const containerDisposable = toDisposable(() => container.remove());1130const disposable = combinedDisposable(onChangeDisposable, containerDisposable);11311132let viewSize: ViewItemSize;11331134if (typeof size === 'number') {1135viewSize = size;1136} else {1137if (size.type === 'auto') {1138if (this.areViewsDistributed()) {1139size = { type: 'distribute' };1140} else {1141size = { type: 'split', index: size.index };1142}1143}11441145if (size.type === 'split') {1146viewSize = this.getViewSize(size.index) / 2;1147} else if (size.type === 'invisible') {1148viewSize = { cachedVisibleSize: size.cachedVisibleSize };1149} else {1150viewSize = view.minimumSize;1151}1152}11531154const item = this.orientation === Orientation.VERTICAL1155? new VerticalViewItem(container, view, viewSize, disposable)1156: new HorizontalViewItem(container, view, viewSize, disposable);11571158this.viewItems.splice(index, 0, item);11591160// Add sash1161if (this.viewItems.length > 1) {1162const opts = { orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash };11631164const sash = this.orientation === Orientation.VERTICAL1165? new Sash(this.sashContainer, { getHorizontalSashTop: s => this.getSashPosition(s), getHorizontalSashWidth: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.HORIZONTAL })1166: new Sash(this.sashContainer, { getVerticalSashLeft: s => this.getSashPosition(s), getVerticalSashHeight: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.VERTICAL });11671168const sashEventMapper = this.orientation === Orientation.VERTICAL1169? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey })1170: (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey });11711172const onStart = Event.map(sash.onDidStart, sashEventMapper);1173const onStartDisposable = onStart(this.onSashStart, this);1174const onChange = Event.map(sash.onDidChange, sashEventMapper);1175const onChangeDisposable = onChange(this.onSashChange, this);1176const onEnd = Event.map(sash.onDidEnd, () => this.sashItems.findIndex(item => item.sash === sash));1177const onEndDisposable = onEnd(this.onSashEnd, this);11781179const onDidResetDisposable = sash.onDidReset(() => {1180const index = this.sashItems.findIndex(item => item.sash === sash);1181const upIndexes = range(index, -1);1182const downIndexes = range(index + 1, this.viewItems.length);1183const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);1184const snapAfterIndex = this.findFirstSnapIndex(downIndexes);11851186if (typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible) {1187return;1188}11891190if (typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible) {1191return;1192}11931194this._onDidSashReset.fire(index);1195});11961197const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash);1198const sashItem: ISashItem = { sash, disposable };11991200this.sashItems.splice(index - 1, 0, sashItem);1201}12021203container.appendChild(view.element);12041205let highPriorityIndexes: number[] | undefined;12061207if (typeof size !== 'number' && size.type === 'split') {1208highPriorityIndexes = [size.index];1209}12101211if (!skipLayout) {1212this.relayout([index], highPriorityIndexes);1213}121412151216if (!skipLayout && typeof size !== 'number' && size.type === 'distribute') {1217this.distributeViewSizes();1218}12191220} finally {1221this.state = State.Idle;1222}1223}12241225private relayout(lowPriorityIndexes?: number[], highPriorityIndexes?: number[]): void {1226const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);12271228this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes);1229this.distributeEmptySpace();1230this.layoutViews();1231this.saveProportions();1232}12331234private resize(1235index: number,1236delta: number,1237sizes = this.viewItems.map(i => i.size),1238lowPriorityIndexes?: number[],1239highPriorityIndexes?: number[],1240overloadMinDelta: number = Number.NEGATIVE_INFINITY,1241overloadMaxDelta: number = Number.POSITIVE_INFINITY,1242snapBefore?: ISashDragSnapState,1243snapAfter?: ISashDragSnapState1244): number {1245if (index < 0 || index >= this.viewItems.length) {1246return 0;1247}12481249const upIndexes = range(index, -1);1250const downIndexes = range(index + 1, this.viewItems.length);12511252if (highPriorityIndexes) {1253for (const index of highPriorityIndexes) {1254pushToStart(upIndexes, index);1255pushToStart(downIndexes, index);1256}1257}12581259if (lowPriorityIndexes) {1260for (const index of lowPriorityIndexes) {1261pushToEnd(upIndexes, index);1262pushToEnd(downIndexes, index);1263}1264}12651266const upItems = upIndexes.map(i => this.viewItems[i]);1267const upSizes = upIndexes.map(i => sizes[i]);12681269const downItems = downIndexes.map(i => this.viewItems[i]);1270const downSizes = downIndexes.map(i => sizes[i]);12711272const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0);1273const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].maximumSize - sizes[i]), 0);1274const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);1275const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].maximumSize), 0);1276const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta);1277const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta);12781279let snapped = false;12801281if (snapBefore) {1282const snapView = this.viewItems[snapBefore.index];1283const visible = delta >= snapBefore.limitDelta;1284snapped = visible !== snapView.visible;1285snapView.setVisible(visible, snapBefore.size);1286}12871288if (!snapped && snapAfter) {1289const snapView = this.viewItems[snapAfter.index];1290const visible = delta < snapAfter.limitDelta;1291snapped = visible !== snapView.visible;1292snapView.setVisible(visible, snapAfter.size);1293}12941295if (snapped) {1296return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, overloadMaxDelta);1297}12981299delta = clamp(delta, minDelta, maxDelta);13001301for (let i = 0, deltaUp = delta; i < upItems.length; i++) {1302const item = upItems[i];1303const size = clamp(upSizes[i] + deltaUp, item.minimumSize, item.maximumSize);1304const viewDelta = size - upSizes[i];13051306deltaUp -= viewDelta;1307item.size = size;1308}13091310for (let i = 0, deltaDown = delta; i < downItems.length; i++) {1311const item = downItems[i];1312const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize);1313const viewDelta = size - downSizes[i];13141315deltaDown += viewDelta;1316item.size = size;1317}13181319return delta;1320}13211322private distributeEmptySpace(lowPriorityIndex?: number): void {1323const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);1324let emptyDelta = this.size - contentSize;13251326const indexes = range(this.viewItems.length - 1, -1);1327const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);1328const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);13291330for (const index of highPriorityIndexes) {1331pushToStart(indexes, index);1332}13331334for (const index of lowPriorityIndexes) {1335pushToEnd(indexes, index);1336}13371338if (typeof lowPriorityIndex === 'number') {1339pushToEnd(indexes, lowPriorityIndex);1340}13411342for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) {1343const item = this.viewItems[indexes[i]];1344const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize);1345const viewDelta = size - item.size;13461347emptyDelta -= viewDelta;1348item.size = size;1349}1350}13511352private layoutViews(): void {1353// Save new content size1354this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);13551356// Layout views1357let offset = 0;13581359for (const viewItem of this.viewItems) {1360viewItem.layout(offset, this.layoutContext);1361offset += viewItem.size;1362}13631364// Layout sashes1365this.sashItems.forEach(item => item.sash.layout());1366this.updateSashEnablement();1367this.updateScrollableElement();1368}13691370private updateScrollableElement(): void {1371if (this.orientation === Orientation.VERTICAL) {1372this.scrollableElement.setScrollDimensions({1373height: this.size,1374scrollHeight: this._contentSize1375});1376} else {1377this.scrollableElement.setScrollDimensions({1378width: this.size,1379scrollWidth: this._contentSize1380});1381}1382}13831384private updateSashEnablement(): void {1385let previous = false;1386const collapsesDown = this.viewItems.map(i => previous = (i.size - i.minimumSize > 0) || previous);13871388previous = false;1389const expandsDown = this.viewItems.map(i => previous = (i.maximumSize - i.size > 0) || previous);13901391const reverseViews = [...this.viewItems].reverse();1392previous = false;1393const collapsesUp = reverseViews.map(i => previous = (i.size - i.minimumSize > 0) || previous).reverse();13941395previous = false;1396const expandsUp = reverseViews.map(i => previous = (i.maximumSize - i.size > 0) || previous).reverse();13971398let position = 0;1399for (let index = 0; index < this.sashItems.length; index++) {1400const { sash } = this.sashItems[index];1401const viewItem = this.viewItems[index];1402position += viewItem.size;14031404const min = !(collapsesDown[index] && expandsUp[index + 1]);1405const max = !(expandsDown[index] && collapsesUp[index + 1]);14061407if (min && max) {1408const upIndexes = range(index, -1);1409const downIndexes = range(index + 1, this.viewItems.length);1410const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);1411const snapAfterIndex = this.findFirstSnapIndex(downIndexes);14121413const snappedBefore = typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible;1414const snappedAfter = typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible;14151416if (snappedBefore && collapsesUp[index] && (position > 0 || this.startSnappingEnabled)) {1417sash.state = SashState.AtMinimum;1418} else if (snappedAfter && collapsesDown[index] && (position < this._contentSize || this.endSnappingEnabled)) {1419sash.state = SashState.AtMaximum;1420} else {1421sash.state = SashState.Disabled;1422}1423} else if (min && !max) {1424sash.state = SashState.AtMinimum;1425} else if (!min && max) {1426sash.state = SashState.AtMaximum;1427} else {1428sash.state = SashState.Enabled;1429}1430}1431}14321433private getSashPosition(sash: Sash): number {1434let position = 0;14351436for (let i = 0; i < this.sashItems.length; i++) {1437position += this.viewItems[i].size;14381439if (this.sashItems[i].sash === sash) {1440return position;1441}1442}14431444return 0;1445}14461447private findFirstSnapIndex(indexes: number[]): number | undefined {1448// visible views first1449for (const index of indexes) {1450const viewItem = this.viewItems[index];14511452if (!viewItem.visible) {1453continue;1454}14551456if (viewItem.snap) {1457return index;1458}1459}14601461// then, hidden views1462for (const index of indexes) {1463const viewItem = this.viewItems[index];14641465if (viewItem.visible && viewItem.maximumSize - viewItem.minimumSize > 0) {1466return undefined;1467}14681469if (!viewItem.visible && viewItem.snap) {1470return index;1471}1472}14731474return undefined;1475}14761477private areViewsDistributed() {1478let min = undefined, max = undefined;14791480for (const view of this.viewItems) {1481min = min === undefined ? view.size : Math.min(min, view.size);1482max = max === undefined ? view.size : Math.max(max, view.size);14831484if (max - min > 2) {1485return false;1486}1487}14881489return true;1490}14911492override dispose(): void {1493this.sashDragState?.disposable.dispose();14941495dispose(this.viewItems);1496this.viewItems = [];14971498this.sashItems.forEach(i => i.disposable.dispose());1499this.sashItems = [];15001501super.dispose();1502}1503}150415051506