mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-24 18:49:00 +01:00
1674 lines
51 KiB
TypeScript
1674 lines
51 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { $, addDisposableListener, append, getWindow, scheduleAtNextAnimationFrame } from '../../dom.js';
|
|
import { DomEmitter } from '../../event.js';
|
|
import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js';
|
|
import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js';
|
|
import { pushToEnd, pushToStart, range } from '../../../common/arrays.js';
|
|
import { Color } from '../../../common/color.js';
|
|
import { Emitter, Event } from '../../../common/event.js';
|
|
import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js';
|
|
import { clamp } from '../../../common/numbers.js';
|
|
import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js';
|
|
import * as types from '../../../common/types.js';
|
|
import { isMotionReduced, parseCubicBezier, scaleDuration, solveCubicBezier } from '../motion/motion.js';
|
|
import './splitview.css';
|
|
export { Orientation } from '../sash/sash.js';
|
|
|
|
export interface ISplitViewStyles {
|
|
readonly separatorBorder: Color;
|
|
}
|
|
|
|
const defaultStyles: ISplitViewStyles = {
|
|
separatorBorder: Color.transparent
|
|
};
|
|
|
|
export const enum LayoutPriority {
|
|
Normal,
|
|
Low,
|
|
High
|
|
}
|
|
|
|
/**
|
|
* The interface to implement for views within a {@link SplitView}.
|
|
*
|
|
* An optional {@link TLayoutContext layout context type} may be used in order to
|
|
* pass along layout contextual data from the {@link SplitView.layout} method down
|
|
* to each view's {@link IView.layout} calls.
|
|
*/
|
|
export interface IView<TLayoutContext = undefined> {
|
|
|
|
/**
|
|
* The DOM element for this view.
|
|
*/
|
|
readonly element: HTMLElement;
|
|
|
|
/**
|
|
* A minimum size for this view.
|
|
*
|
|
* @remarks If none, set it to `0`.
|
|
*/
|
|
readonly minimumSize: number;
|
|
|
|
/**
|
|
* A maximum size for this view.
|
|
*
|
|
* @remarks If none, set it to `Number.POSITIVE_INFINITY`.
|
|
*/
|
|
readonly maximumSize: number;
|
|
|
|
/**
|
|
* The priority of the view when the {@link SplitView.resize layout} algorithm
|
|
* runs. Views with higher priority will be resized first.
|
|
*
|
|
* @remarks Only used when `proportionalLayout` is false.
|
|
*/
|
|
readonly priority?: LayoutPriority;
|
|
|
|
/**
|
|
* If the {@link SplitView} supports {@link ISplitViewOptions.proportionalLayout proportional layout},
|
|
* this property allows for finer control over the proportional layout algorithm, per view.
|
|
*
|
|
* @defaultValue `true`
|
|
*/
|
|
readonly proportionalLayout?: boolean;
|
|
|
|
/**
|
|
* Whether the view will snap whenever the user reaches its minimum size or
|
|
* attempts to grow it beyond the minimum size.
|
|
*
|
|
* @defaultValue `false`
|
|
*/
|
|
readonly snap?: boolean;
|
|
|
|
/**
|
|
* View instances are supposed to fire the {@link IView.onDidChange} event whenever
|
|
* any of the constraint properties have changed:
|
|
*
|
|
* - {@link IView.minimumSize}
|
|
* - {@link IView.maximumSize}
|
|
* - {@link IView.priority}
|
|
* - {@link IView.snap}
|
|
*
|
|
* The SplitView will relayout whenever that happens. The event can optionally emit
|
|
* the view's preferred size for that relayout.
|
|
*/
|
|
readonly onDidChange: Event<number | undefined>;
|
|
|
|
/**
|
|
* This will be called by the {@link SplitView} during layout. A view meant to
|
|
* pass along the layout information down to its descendants.
|
|
*
|
|
* @param size The size of this view, in pixels.
|
|
* @param offset The offset of this view, relative to the start of the {@link SplitView}.
|
|
* @param context The optional {@link IView layout context} passed to {@link SplitView.layout}.
|
|
*/
|
|
layout(size: number, offset: number, context: TLayoutContext | undefined): void;
|
|
|
|
/**
|
|
* This will be called by the {@link SplitView} whenever this view is made
|
|
* visible or hidden.
|
|
*
|
|
* @param visible Whether the view becomes visible.
|
|
*/
|
|
setVisible?(visible: boolean): void;
|
|
}
|
|
|
|
/**
|
|
* A descriptor for a {@link SplitView} instance.
|
|
*/
|
|
export interface ISplitViewDescriptor<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {
|
|
|
|
/**
|
|
* The layout size of the {@link SplitView}.
|
|
*/
|
|
readonly size: number;
|
|
|
|
/**
|
|
* Descriptors for each {@link IView view}.
|
|
*/
|
|
readonly views: {
|
|
|
|
/**
|
|
* Whether the {@link IView view} is visible.
|
|
*
|
|
* @defaultValue `true`
|
|
*/
|
|
readonly visible?: boolean;
|
|
|
|
/**
|
|
* The size of the {@link IView view}.
|
|
*
|
|
* @defaultValue `true`
|
|
*/
|
|
readonly size: number;
|
|
|
|
/**
|
|
* The size of the {@link IView view}.
|
|
*
|
|
* @defaultValue `true`
|
|
*/
|
|
readonly view: TView;
|
|
}[];
|
|
}
|
|
|
|
export interface ISplitViewOptions<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {
|
|
|
|
/**
|
|
* Which axis the views align on.
|
|
*
|
|
* @defaultValue `Orientation.VERTICAL`
|
|
*/
|
|
readonly orientation?: Orientation;
|
|
|
|
/**
|
|
* Styles overriding the {@link defaultStyles default ones}.
|
|
*/
|
|
readonly styles?: ISplitViewStyles;
|
|
|
|
/**
|
|
* Make Alt-drag the default drag operation.
|
|
*/
|
|
readonly inverseAltBehavior?: boolean;
|
|
|
|
/**
|
|
* Resize each view proportionally when resizing the SplitView.
|
|
*
|
|
* @defaultValue `true`
|
|
*/
|
|
readonly proportionalLayout?: boolean;
|
|
|
|
/**
|
|
* An initial description of this {@link SplitView} instance, allowing
|
|
* to initialze all views within the ctor.
|
|
*/
|
|
readonly descriptor?: ISplitViewDescriptor<TLayoutContext, TView>;
|
|
|
|
/**
|
|
* The scrollbar visibility setting for whenever the views within
|
|
* the {@link SplitView} overflow.
|
|
*/
|
|
readonly scrollbarVisibility?: ScrollbarVisibility;
|
|
|
|
/**
|
|
* Override the orthogonal size of sashes.
|
|
*/
|
|
readonly getSashOrthogonalSize?: () => number;
|
|
}
|
|
|
|
interface ISashEvent {
|
|
readonly sash: Sash;
|
|
readonly start: number;
|
|
readonly current: number;
|
|
readonly alt: boolean;
|
|
}
|
|
|
|
type ViewItemSize = number | { cachedVisibleSize: number };
|
|
|
|
abstract class ViewItem<TLayoutContext, TView extends IView<TLayoutContext>> {
|
|
|
|
private _size: number;
|
|
set size(size: number) {
|
|
this._size = size;
|
|
}
|
|
|
|
get size(): number {
|
|
return this._size;
|
|
}
|
|
|
|
private _cachedVisibleSize: number | undefined = undefined;
|
|
get cachedVisibleSize(): number | undefined { return this._cachedVisibleSize; }
|
|
|
|
get visible(): boolean {
|
|
return typeof this._cachedVisibleSize === 'undefined';
|
|
}
|
|
|
|
setVisible(visible: boolean, size?: number): void {
|
|
if (visible === this.visible) {
|
|
return;
|
|
}
|
|
|
|
if (visible) {
|
|
this.size = clamp(this._cachedVisibleSize!, this.viewMinimumSize, this.viewMaximumSize);
|
|
this._cachedVisibleSize = undefined;
|
|
} else {
|
|
this._cachedVisibleSize = typeof size === 'number' ? size : this.size;
|
|
this.size = 0;
|
|
}
|
|
|
|
this.container.classList.toggle('visible', visible);
|
|
|
|
try {
|
|
this.view.setVisible?.(visible);
|
|
} catch (e) {
|
|
console.error('Splitview: Failed to set visible view');
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
get minimumSize(): number { return this.visible ? this.view.minimumSize : 0; }
|
|
get viewMinimumSize(): number { return this.view.minimumSize; }
|
|
|
|
get maximumSize(): number { return this.visible ? this.view.maximumSize : 0; }
|
|
get viewMaximumSize(): number { return this.view.maximumSize; }
|
|
|
|
get priority(): LayoutPriority | undefined { return this.view.priority; }
|
|
get proportionalLayout(): boolean { return this.view.proportionalLayout ?? true; }
|
|
get snap(): boolean { return !!this.view.snap; }
|
|
|
|
set enabled(enabled: boolean) {
|
|
this.container.style.pointerEvents = enabled ? '' : 'none';
|
|
}
|
|
|
|
constructor(
|
|
protected container: HTMLElement,
|
|
readonly view: TView,
|
|
size: ViewItemSize,
|
|
private disposable: IDisposable
|
|
) {
|
|
if (typeof size === 'number') {
|
|
this._size = size;
|
|
this._cachedVisibleSize = undefined;
|
|
container.classList.add('visible');
|
|
} else {
|
|
this._size = 0;
|
|
this._cachedVisibleSize = size.cachedVisibleSize;
|
|
}
|
|
}
|
|
|
|
layout(offset: number, layoutContext: TLayoutContext | undefined): void {
|
|
this.layoutContainer(offset);
|
|
|
|
try {
|
|
this.view.layout(this.size, offset, layoutContext);
|
|
} catch (e) {
|
|
console.error('Splitview: Failed to layout view');
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
abstract layoutContainer(offset: number): void;
|
|
|
|
dispose(): void {
|
|
this.disposable.dispose();
|
|
}
|
|
}
|
|
|
|
class VerticalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {
|
|
|
|
layoutContainer(offset: number): void {
|
|
this.container.style.top = `${offset}px`;
|
|
this.container.style.height = `${this.size}px`;
|
|
}
|
|
}
|
|
|
|
class HorizontalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {
|
|
|
|
layoutContainer(offset: number): void {
|
|
this.container.style.left = `${offset}px`;
|
|
this.container.style.width = `${this.size}px`;
|
|
}
|
|
}
|
|
|
|
interface ISashItem {
|
|
sash: Sash;
|
|
disposable: IDisposable;
|
|
}
|
|
|
|
interface ISashDragSnapState {
|
|
readonly index: number;
|
|
readonly limitDelta: number;
|
|
readonly size: number;
|
|
}
|
|
|
|
interface ISashDragState {
|
|
index: number;
|
|
start: number;
|
|
current: number;
|
|
sizes: number[];
|
|
minDelta: number;
|
|
maxDelta: number;
|
|
alt: boolean;
|
|
snapBefore: ISashDragSnapState | undefined;
|
|
snapAfter: ISashDragSnapState | undefined;
|
|
disposable: IDisposable;
|
|
}
|
|
|
|
enum State {
|
|
Idle,
|
|
Busy
|
|
}
|
|
|
|
/**
|
|
* When adding or removing views, uniformly distribute the entire split view space among
|
|
* all views.
|
|
*/
|
|
export type DistributeSizing = { type: 'distribute' };
|
|
|
|
/**
|
|
* When adding a view, make space for it by reducing the size of another view,
|
|
* indexed by the provided `index`.
|
|
*/
|
|
export type SplitSizing = { type: 'split'; index: number };
|
|
|
|
/**
|
|
* When adding a view, use DistributeSizing when all pre-existing views are
|
|
* distributed evenly, otherwise use SplitSizing.
|
|
*/
|
|
export type AutoSizing = { type: 'auto'; index: number };
|
|
|
|
/**
|
|
* When adding or removing views, assume the view is invisible.
|
|
*/
|
|
export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number };
|
|
|
|
/**
|
|
* When adding or removing views, the sizing provides fine grained
|
|
* control over how other views get resized.
|
|
*/
|
|
export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing;
|
|
|
|
export namespace Sizing {
|
|
|
|
/**
|
|
* When adding or removing views, distribute the delta space among
|
|
* all other views.
|
|
*/
|
|
export const Distribute: DistributeSizing = { type: 'distribute' };
|
|
|
|
/**
|
|
* When adding or removing views, split the delta space with another
|
|
* specific view, indexed by the provided `index`.
|
|
*/
|
|
export function Split(index: number): SplitSizing { return { type: 'split', index }; }
|
|
|
|
/**
|
|
* When adding a view, use DistributeSizing when all pre-existing views are
|
|
* distributed evenly, otherwise use SplitSizing.
|
|
*/
|
|
export function Auto(index: number): AutoSizing { return { type: 'auto', index }; }
|
|
|
|
/**
|
|
* When adding or removing views, assume the view is invisible.
|
|
*/
|
|
export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; }
|
|
}
|
|
|
|
/**
|
|
* The {@link SplitView} is the UI component which implements a one dimensional
|
|
* flex-like layout algorithm for a collection of {@link IView} instances, which
|
|
* are essentially HTMLElement instances with the following size constraints:
|
|
*
|
|
* - {@link IView.minimumSize}
|
|
* - {@link IView.maximumSize}
|
|
* - {@link IView.priority}
|
|
* - {@link IView.snap}
|
|
*
|
|
* In case the SplitView doesn't have enough size to fit all views, it will overflow
|
|
* its content with a scrollbar.
|
|
*
|
|
* In between each pair of views there will be a {@link Sash} allowing the user
|
|
* to resize the views, making sure the constraints are respected.
|
|
*
|
|
* An optional {@link TLayoutContext layout context type} may be used in order to
|
|
* pass along layout contextual data from the {@link SplitView.layout} method down
|
|
* to each view's {@link IView.layout} calls.
|
|
*
|
|
* Features:
|
|
* - Flex-like layout algorithm
|
|
* - Snap support
|
|
* - Orthogonal sash support, for corner sashes
|
|
* - View hide/show support
|
|
* - View swap/move support
|
|
* - Alt key modifier behavior, macOS style
|
|
*/
|
|
export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> extends Disposable {
|
|
|
|
/**
|
|
* This {@link SplitView}'s orientation.
|
|
*/
|
|
readonly orientation: Orientation;
|
|
|
|
/**
|
|
* The DOM element representing this {@link SplitView}.
|
|
*/
|
|
readonly el: HTMLElement;
|
|
|
|
private sashContainer: HTMLElement;
|
|
private viewContainer: HTMLElement;
|
|
private scrollable: Scrollable;
|
|
private scrollableElement: SmoothScrollableElement;
|
|
private size = 0;
|
|
private layoutContext: TLayoutContext | undefined;
|
|
private _contentSize = 0;
|
|
private proportions: (number | undefined)[] | undefined = undefined;
|
|
private viewItems: ViewItem<TLayoutContext, TView>[] = [];
|
|
sashItems: ISashItem[] = []; // used in tests
|
|
private sashDragState: ISashDragState | undefined;
|
|
private state: State = State.Idle;
|
|
private inverseAltBehavior: boolean;
|
|
private proportionalLayout: boolean;
|
|
private readonly getSashOrthogonalSize: { (): number } | undefined;
|
|
|
|
private _onDidSashChange = this._register(new Emitter<number>());
|
|
private _onDidSashReset = this._register(new Emitter<number>());
|
|
private _orthogonalStartSash: Sash | undefined;
|
|
private _orthogonalEndSash: Sash | undefined;
|
|
private _startSnappingEnabled = true;
|
|
private _endSnappingEnabled = true;
|
|
|
|
/**
|
|
* The sum of all views' sizes.
|
|
*/
|
|
get contentSize(): number { return this._contentSize; }
|
|
|
|
/**
|
|
* Fires whenever the user resizes a {@link Sash sash}.
|
|
*/
|
|
readonly onDidSashChange = this._onDidSashChange.event;
|
|
|
|
/**
|
|
* Fires whenever the user double clicks a {@link Sash sash}.
|
|
*/
|
|
readonly onDidSashReset = this._onDidSashReset.event;
|
|
|
|
/**
|
|
* Fires whenever the split view is scrolled.
|
|
*/
|
|
readonly onDidScroll: Event<ScrollEvent>;
|
|
|
|
/**
|
|
* The amount of views in this {@link SplitView}.
|
|
*/
|
|
get length(): number {
|
|
return this.viewItems.length;
|
|
}
|
|
|
|
/**
|
|
* The minimum size of this {@link SplitView}.
|
|
*/
|
|
get minimumSize(): number {
|
|
return this.viewItems.reduce((r, item) => r + item.minimumSize, 0);
|
|
}
|
|
|
|
/**
|
|
* The maximum size of this {@link SplitView}.
|
|
*/
|
|
get maximumSize(): number {
|
|
return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.maximumSize, 0);
|
|
}
|
|
|
|
get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; }
|
|
get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; }
|
|
get startSnappingEnabled(): boolean { return this._startSnappingEnabled; }
|
|
get endSnappingEnabled(): boolean { return this._endSnappingEnabled; }
|
|
|
|
/**
|
|
* A reference to a sash, perpendicular to all sashes in this {@link SplitView},
|
|
* located at the left- or top-most side of the SplitView.
|
|
* Corner sashes will be created automatically at the intersections.
|
|
*/
|
|
set orthogonalStartSash(sash: Sash | undefined) {
|
|
for (const sashItem of this.sashItems) {
|
|
sashItem.sash.orthogonalStartSash = sash;
|
|
}
|
|
|
|
this._orthogonalStartSash = sash;
|
|
}
|
|
|
|
/**
|
|
* A reference to a sash, perpendicular to all sashes in this {@link SplitView},
|
|
* located at the right- or bottom-most side of the SplitView.
|
|
* Corner sashes will be created automatically at the intersections.
|
|
*/
|
|
set orthogonalEndSash(sash: Sash | undefined) {
|
|
for (const sashItem of this.sashItems) {
|
|
sashItem.sash.orthogonalEndSash = sash;
|
|
}
|
|
|
|
this._orthogonalEndSash = sash;
|
|
}
|
|
|
|
/**
|
|
* The internal sashes within this {@link SplitView}.
|
|
*/
|
|
get sashes(): readonly Sash[] {
|
|
return this.sashItems.map(s => s.sash);
|
|
}
|
|
|
|
/**
|
|
* Enable/disable snapping at the beginning of this {@link SplitView}.
|
|
*/
|
|
set startSnappingEnabled(startSnappingEnabled: boolean) {
|
|
if (this._startSnappingEnabled === startSnappingEnabled) {
|
|
return;
|
|
}
|
|
|
|
this._startSnappingEnabled = startSnappingEnabled;
|
|
this.updateSashEnablement();
|
|
}
|
|
|
|
/**
|
|
* Enable/disable snapping at the end of this {@link SplitView}.
|
|
*/
|
|
set endSnappingEnabled(endSnappingEnabled: boolean) {
|
|
if (this._endSnappingEnabled === endSnappingEnabled) {
|
|
return;
|
|
}
|
|
|
|
this._endSnappingEnabled = endSnappingEnabled;
|
|
this.updateSashEnablement();
|
|
}
|
|
|
|
/**
|
|
* Create a new {@link SplitView} instance.
|
|
*/
|
|
constructor(container: HTMLElement, options: ISplitViewOptions<TLayoutContext, TView> = {}) {
|
|
super();
|
|
|
|
this.orientation = options.orientation ?? Orientation.VERTICAL;
|
|
this.inverseAltBehavior = options.inverseAltBehavior ?? false;
|
|
this.proportionalLayout = options.proportionalLayout ?? true;
|
|
this.getSashOrthogonalSize = options.getSashOrthogonalSize;
|
|
|
|
this.el = document.createElement('div');
|
|
this.el.classList.add('monaco-split-view2');
|
|
this.el.classList.add(this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal');
|
|
container.appendChild(this.el);
|
|
|
|
this.sashContainer = append(this.el, $('.sash-container'));
|
|
this.viewContainer = $('.split-view-container');
|
|
|
|
this.scrollable = this._register(new Scrollable({
|
|
forceIntegerValues: true,
|
|
smoothScrollDuration: 125,
|
|
scheduleAtNextAnimationFrame: callback => scheduleAtNextAnimationFrame(getWindow(this.el), callback),
|
|
}));
|
|
this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, {
|
|
vertical: this.orientation === Orientation.VERTICAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden,
|
|
horizontal: this.orientation === Orientation.HORIZONTAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden
|
|
}, this.scrollable));
|
|
|
|
// https://github.com/microsoft/vscode/issues/157737
|
|
const onDidScrollViewContainer = this._register(new DomEmitter(this.viewContainer, 'scroll')).event;
|
|
this._register(onDidScrollViewContainer(_ => {
|
|
const position = this.scrollableElement.getScrollPosition();
|
|
const scrollLeft = Math.abs(this.viewContainer.scrollLeft - position.scrollLeft) <= 1 ? undefined : this.viewContainer.scrollLeft;
|
|
const scrollTop = Math.abs(this.viewContainer.scrollTop - position.scrollTop) <= 1 ? undefined : this.viewContainer.scrollTop;
|
|
|
|
if (scrollLeft !== undefined || scrollTop !== undefined) {
|
|
this.scrollableElement.setScrollPosition({ scrollLeft, scrollTop });
|
|
}
|
|
}));
|
|
|
|
this.onDidScroll = this.scrollableElement.onScroll;
|
|
this._register(this.onDidScroll(e => {
|
|
if (e.scrollTopChanged) {
|
|
this.viewContainer.scrollTop = e.scrollTop;
|
|
}
|
|
|
|
if (e.scrollLeftChanged) {
|
|
this.viewContainer.scrollLeft = e.scrollLeft;
|
|
}
|
|
}));
|
|
|
|
append(this.el, this.scrollableElement.getDomNode());
|
|
|
|
this.style(options.styles || defaultStyles);
|
|
|
|
// We have an existing set of view, add them now
|
|
if (options.descriptor) {
|
|
this.size = options.descriptor.size;
|
|
options.descriptor.views.forEach((viewDescriptor, index) => {
|
|
const sizing = types.isUndefined(viewDescriptor.visible) || viewDescriptor.visible ? viewDescriptor.size : { type: 'invisible', cachedVisibleSize: viewDescriptor.size } satisfies InvisibleSizing;
|
|
|
|
const view = viewDescriptor.view;
|
|
this.doAddView(view, sizing, index, true);
|
|
});
|
|
|
|
// Initialize content size and proportions for first layout
|
|
this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
|
this.saveProportions();
|
|
}
|
|
}
|
|
|
|
style(styles: ISplitViewStyles): void {
|
|
if (styles.separatorBorder.isTransparent()) {
|
|
this.el.classList.remove('separator-border');
|
|
this.el.style.removeProperty('--separator-border');
|
|
} else {
|
|
this.el.classList.add('separator-border');
|
|
this.el.style.setProperty('--separator-border', styles.separatorBorder.toString());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a {@link IView view} to this {@link SplitView}.
|
|
*
|
|
* @param view The view to add.
|
|
* @param size Either a fixed size, or a dynamic {@link Sizing} strategy.
|
|
* @param index The index to insert the view on.
|
|
* @param skipLayout Whether layout should be skipped.
|
|
*/
|
|
addView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {
|
|
this.doAddView(view, size, index, skipLayout);
|
|
}
|
|
|
|
/**
|
|
* Remove a {@link IView view} from this {@link SplitView}.
|
|
*
|
|
* @param index The index where the {@link IView view} is located.
|
|
* @param sizing Whether to distribute other {@link IView view}'s sizes.
|
|
*/
|
|
removeView(index: number, sizing?: Sizing): TView {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
throw new Error('Index out of bounds');
|
|
}
|
|
|
|
if (this.state !== State.Idle) {
|
|
throw new Error('Cant modify splitview');
|
|
}
|
|
|
|
this.state = State.Busy;
|
|
|
|
try {
|
|
if (sizing?.type === 'auto') {
|
|
if (this.areViewsDistributed()) {
|
|
sizing = { type: 'distribute' };
|
|
} else {
|
|
sizing = { type: 'split', index: sizing.index };
|
|
}
|
|
}
|
|
|
|
// Save referene view, in case of `split` sizing
|
|
const referenceViewItem = sizing?.type === 'split' ? this.viewItems[sizing.index] : undefined;
|
|
|
|
// Remove view
|
|
const viewItemToRemove = this.viewItems.splice(index, 1)[0];
|
|
|
|
// Resize reference view, in case of `split` sizing
|
|
if (referenceViewItem) {
|
|
referenceViewItem.size += viewItemToRemove.size;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
this.relayout();
|
|
|
|
if (sizing?.type === 'distribute') {
|
|
this.distributeViewSizes();
|
|
}
|
|
|
|
const result = viewItemToRemove.view;
|
|
viewItemToRemove.dispose();
|
|
return result;
|
|
|
|
} finally {
|
|
this.state = State.Idle;
|
|
}
|
|
}
|
|
|
|
removeAllViews(): TView[] {
|
|
if (this.state !== State.Idle) {
|
|
throw new Error('Cant modify splitview');
|
|
}
|
|
|
|
this.state = State.Busy;
|
|
|
|
try {
|
|
const viewItems = this.viewItems.splice(0, this.viewItems.length);
|
|
|
|
for (const viewItem of viewItems) {
|
|
viewItem.dispose();
|
|
}
|
|
|
|
const sashItems = this.sashItems.splice(0, this.sashItems.length);
|
|
|
|
for (const sashItem of sashItems) {
|
|
sashItem.disposable.dispose();
|
|
}
|
|
|
|
this.relayout();
|
|
return viewItems.map(i => i.view);
|
|
|
|
} finally {
|
|
this.state = State.Idle;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move a {@link IView view} to a different index.
|
|
*
|
|
* @param from The source index.
|
|
* @param to The target index.
|
|
*/
|
|
moveView(from: number, to: number): void {
|
|
if (this.state !== State.Idle) {
|
|
throw new Error('Cant modify splitview');
|
|
}
|
|
|
|
const cachedVisibleSize = this.getViewCachedVisibleSize(from);
|
|
const sizing = typeof cachedVisibleSize === 'undefined' ? this.getViewSize(from) : Sizing.Invisible(cachedVisibleSize);
|
|
const view = this.removeView(from);
|
|
this.addView(view, sizing, to);
|
|
}
|
|
|
|
|
|
/**
|
|
* Swap two {@link IView views}.
|
|
*
|
|
* @param from The source index.
|
|
* @param to The target index.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Returns whether the {@link IView view} is visible.
|
|
*
|
|
* @param index The {@link IView view} index.
|
|
*/
|
|
isViewVisible(index: number): boolean {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
throw new Error('Index out of bounds');
|
|
}
|
|
|
|
const viewItem = this.viewItems[index];
|
|
return viewItem.visible;
|
|
}
|
|
|
|
/**
|
|
* Set a {@link IView view}'s visibility.
|
|
*
|
|
* @param index The {@link IView view} index.
|
|
* @param visible Whether the {@link IView view} should be visible.
|
|
*/
|
|
setViewVisible(index: number, visible: boolean): void {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
throw new Error('Index out of bounds');
|
|
}
|
|
|
|
// Cancel any in-flight animation before changing visibility.
|
|
// An animated setViewVisibleAnimated interpolates ALL view sizes each
|
|
// frame, so a concurrent non-animated visibility change on a different
|
|
// view in this splitview would be overwritten on the next frame.
|
|
// Snapping the animation to its final state first prevents that.
|
|
this._cleanupMotion?.();
|
|
this._cleanupMotion = undefined;
|
|
|
|
const viewItem = this.viewItems[index];
|
|
viewItem.setVisible(visible);
|
|
|
|
this.distributeEmptySpace(index);
|
|
this.layoutViews();
|
|
this.saveProportions();
|
|
}
|
|
|
|
/**
|
|
* Set a {@link IView view}'s visibility with a smooth animation.
|
|
*
|
|
* Uses `requestAnimationFrame` to interpolate all view sizes on each frame,
|
|
* which naturally cascades layout changes through nested splitviews in the
|
|
* grid hierarchy (e.g., the bottom panel resizing when the sidebar animates).
|
|
*
|
|
* If motion is reduced (via accessibility settings), falls back to instant
|
|
* {@link setViewVisible}.
|
|
*
|
|
* @param index The {@link IView view} index.
|
|
* @param visible Whether the {@link IView view} should be visible.
|
|
* @param duration The transition duration in milliseconds.
|
|
* @param easing The CSS `cubic-bezier(...)` easing function string.
|
|
* @param onComplete Optional callback invoked when the animation finishes.
|
|
* NOT called if the animation is canceled via {@link IDisposable.dispose}.
|
|
* @returns A disposable that cancels the animation if disposed before completion.
|
|
*/
|
|
setViewVisibleAnimated(index: number, visible: boolean, duration: number, easing: string, onComplete?: () => void): IDisposable {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
throw new Error('Index out of bounds');
|
|
}
|
|
|
|
// Cancel any in-flight animation
|
|
this._cleanupMotion?.();
|
|
this._cleanupMotion = undefined;
|
|
|
|
const viewItem = this.viewItems[index];
|
|
|
|
// If motion is reduced or already in target state, use instant path
|
|
if (viewItem.visible === visible || isMotionReduced(this.el)) {
|
|
this.setViewVisible(index, visible);
|
|
return toDisposable(() => { });
|
|
}
|
|
|
|
const container = this.viewContainer.children[index] as HTMLElement;
|
|
const window = getWindow(this.el);
|
|
let disposed = false;
|
|
let rafId: number | undefined;
|
|
|
|
// 1. Snapshot sizes BEFORE the visibility change (the animation start state)
|
|
const startSizes = this.viewItems.map(v => v.size);
|
|
|
|
// 2. Apply the target visibility to the model instantly.
|
|
// This computes final sizes, fires events, updates sashes, etc.
|
|
this.setViewVisible(index, visible);
|
|
|
|
// 3. Snapshot sizes AFTER the visibility change (the animation end state)
|
|
const finalSizes = this.viewItems.map(v => v.size);
|
|
|
|
// 4. Restore start sizes so we can animate FROM them
|
|
for (let i = 0; i < this.viewItems.length; i++) {
|
|
this.viewItems[i].size = startSizes[i];
|
|
}
|
|
|
|
// 5. For hiding: the target container lost .visible class (→ display:none).
|
|
// Restore it so content stays visible during the animation.
|
|
if (!visible) {
|
|
container.classList.add('visible');
|
|
}
|
|
|
|
// 6. Clip overflow on the target container while it shrinks.
|
|
// Only apply for HIDE animations - for SHOW, we leave overflow alone
|
|
// so that box-shadow / visual effects on the child Part are not clipped
|
|
// by the parent container during the animation.
|
|
if (!visible) {
|
|
container.style.overflow = 'hidden';
|
|
}
|
|
|
|
// 6b. Set initial opacity for fade effect
|
|
container.style.opacity = visible ? '0' : '1';
|
|
|
|
// 7. Scale duration based on pixel distance for consistent perceived velocity
|
|
const pixelDistance = Math.abs(finalSizes[index] - startSizes[index]);
|
|
duration = scaleDuration(duration, pixelDistance);
|
|
|
|
// 8. Render the start state
|
|
this.layoutViews();
|
|
|
|
// 9. Parse easing for JS evaluation
|
|
const [x1, y1, x2, y2] = parseCubicBezier(easing);
|
|
|
|
// Helper: snap all sizes to final state and clean up
|
|
const applyFinalState = () => {
|
|
for (let i = 0; i < this.viewItems.length; i++) {
|
|
this.viewItems[i].size = finalSizes[i];
|
|
}
|
|
container.style.opacity = '';
|
|
if (!visible) {
|
|
container.classList.remove('visible');
|
|
container.style.overflow = '';
|
|
}
|
|
this.layoutViews();
|
|
this.saveProportions();
|
|
};
|
|
|
|
const cleanup = (completed: boolean) => {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
disposed = true;
|
|
if (rafId !== undefined) {
|
|
window.cancelAnimationFrame(rafId);
|
|
rafId = undefined;
|
|
}
|
|
applyFinalState();
|
|
this._cleanupMotion = undefined;
|
|
if (completed) {
|
|
onComplete?.();
|
|
}
|
|
};
|
|
this._cleanupMotion = () => cleanup(false);
|
|
|
|
// 10. Animate via requestAnimationFrame
|
|
const startTime = performance.now();
|
|
const totalSize = this.size;
|
|
|
|
const animate = () => {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
|
|
const elapsed = performance.now() - startTime;
|
|
const t = Math.min(elapsed / duration, 1);
|
|
const easedT = solveCubicBezier(x1, y1, x2, y2, t);
|
|
|
|
// Interpolate opacity for fade effect
|
|
container.style.opacity = String(visible ? easedT : 1 - easedT);
|
|
|
|
// Interpolate all view sizes
|
|
let runningTotal = 0;
|
|
for (let i = 0; i < this.viewItems.length; i++) {
|
|
if (i === this.viewItems.length - 1) {
|
|
// Last item absorbs rounding errors to maintain total = this.size
|
|
this.viewItems[i].size = totalSize - runningTotal;
|
|
} else {
|
|
const size = Math.round(
|
|
startSizes[i] + (finalSizes[i] - startSizes[i]) * easedT
|
|
);
|
|
this.viewItems[i].size = size;
|
|
runningTotal += size;
|
|
}
|
|
}
|
|
|
|
this.layoutViews();
|
|
|
|
if (t < 1) {
|
|
rafId = window.requestAnimationFrame(animate);
|
|
} else {
|
|
cleanup(true);
|
|
}
|
|
};
|
|
|
|
rafId = window.requestAnimationFrame(animate);
|
|
|
|
return toDisposable(() => cleanup(false));
|
|
}
|
|
|
|
private _cleanupMotion: (() => void) | undefined;
|
|
|
|
/**
|
|
* Returns the {@link IView view}'s size previously to being hidden.
|
|
*
|
|
* @param index The {@link IView view} index.
|
|
*/
|
|
getViewCachedVisibleSize(index: number): number | undefined {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
throw new Error('Index out of bounds');
|
|
}
|
|
|
|
const viewItem = this.viewItems[index];
|
|
return viewItem.cachedVisibleSize;
|
|
}
|
|
|
|
/**
|
|
* Layout the {@link SplitView}.
|
|
*
|
|
* @param size The entire size of the {@link SplitView}.
|
|
* @param layoutContext An optional layout context to pass along to {@link IView views}.
|
|
*/
|
|
layout(size: number, layoutContext?: TLayoutContext): void {
|
|
const previousSize = Math.max(this.size, this._contentSize);
|
|
this.size = size;
|
|
this.layoutContext = layoutContext;
|
|
|
|
if (!this.proportions) {
|
|
const indexes = range(this.viewItems.length);
|
|
const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
|
|
const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);
|
|
|
|
this.resize(this.viewItems.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes);
|
|
} else {
|
|
let total = 0;
|
|
|
|
for (let i = 0; i < this.viewItems.length; i++) {
|
|
const item = this.viewItems[i];
|
|
const proportion = this.proportions[i];
|
|
|
|
if (typeof proportion === 'number') {
|
|
total += proportion;
|
|
} else {
|
|
size -= item.size;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < this.viewItems.length; i++) {
|
|
const item = this.viewItems[i];
|
|
const proportion = this.proportions[i];
|
|
|
|
if (typeof proportion === 'number' && total > 0) {
|
|
item.size = clamp(Math.round(proportion * size / total), item.minimumSize, item.maximumSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.distributeEmptySpace();
|
|
this.layoutViews();
|
|
}
|
|
|
|
private saveProportions(): void {
|
|
if (this.proportionalLayout && this._contentSize > 0) {
|
|
this.proportions = this.viewItems.map(v => v.proportionalLayout && v.visible ? v.size / this._contentSize : undefined);
|
|
}
|
|
}
|
|
|
|
private onSashStart({ sash, start, alt }: ISashEvent): void {
|
|
for (const item of this.viewItems) {
|
|
item.enabled = false;
|
|
}
|
|
|
|
const index = this.sashItems.findIndex(item => item.sash === sash);
|
|
|
|
// This way, we can press Alt while we resize a sash, macOS style!
|
|
const disposable = combinedDisposable(
|
|
addDisposableListener(this.el.ownerDocument.body, 'keydown', e => resetSashDragState(this.sashDragState!.current, e.altKey)),
|
|
addDisposableListener(this.el.ownerDocument.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.minimumSize - viewItem.size) / 2;
|
|
maxDelta = (viewItem.maximumSize - viewItem.size) / 2;
|
|
} else {
|
|
const viewItem = this.viewItems[index + 1];
|
|
minDelta = (viewItem.size - viewItem.maximumSize) / 2;
|
|
maxDelta = (viewItem.size - viewItem.minimumSize) / 2;
|
|
}
|
|
}
|
|
|
|
let snapBefore: ISashDragSnapState | undefined;
|
|
let snapAfter: ISashDragSnapState | undefined;
|
|
|
|
if (!alt) {
|
|
const upIndexes = range(index, -1);
|
|
const downIndexes = range(index + 1, this.viewItems.length);
|
|
const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0);
|
|
const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].viewMaximumSize - sizes[i]), 0);
|
|
const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);
|
|
const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].viewMaximumSize), 0);
|
|
const minDelta = Math.max(minDeltaUp, minDeltaDown);
|
|
const maxDelta = Math.min(maxDeltaDown, maxDeltaUp);
|
|
const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
|
|
const snapAfterIndex = this.findFirstSnapIndex(downIndexes);
|
|
|
|
if (typeof snapBeforeIndex === 'number') {
|
|
const viewItem = this.viewItems[snapBeforeIndex];
|
|
const halfSize = Math.floor(viewItem.viewMinimumSize / 2);
|
|
|
|
snapBefore = {
|
|
index: snapBeforeIndex,
|
|
limitDelta: viewItem.visible ? minDelta - halfSize : minDelta + halfSize,
|
|
size: viewItem.size
|
|
};
|
|
}
|
|
|
|
if (typeof snapAfterIndex === 'number') {
|
|
const viewItem = this.viewItems[snapAfterIndex];
|
|
const halfSize = Math.floor(viewItem.viewMinimumSize / 2);
|
|
|
|
snapAfter = {
|
|
index: snapAfterIndex,
|
|
limitDelta: viewItem.visible ? maxDelta + halfSize : maxDelta - halfSize,
|
|
size: viewItem.size
|
|
};
|
|
}
|
|
}
|
|
|
|
this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, snapBefore, snapAfter, disposable };
|
|
};
|
|
|
|
resetSashDragState(start, alt);
|
|
}
|
|
|
|
private onSashChange({ current }: ISashEvent): void {
|
|
const { index, start, sizes, alt, minDelta, maxDelta, snapBefore, snapAfter } = this.sashDragState!;
|
|
this.sashDragState!.current = current;
|
|
|
|
const delta = current - start;
|
|
const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter);
|
|
|
|
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.maximumSize;
|
|
const newMaxDelta = viewItem.size - viewItem.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();
|
|
|
|
for (const item of this.viewItems) {
|
|
item.enabled = true;
|
|
}
|
|
}
|
|
|
|
private onViewChange(item: ViewItem<TLayoutContext, TView>, 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.minimumSize, item.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.relayout([index], undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resize a {@link IView view} within the {@link SplitView}.
|
|
*
|
|
* @param index The {@link IView view} index.
|
|
* @param size The {@link IView view} size.
|
|
*/
|
|
resizeView(index: number, size: number): void {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
return;
|
|
}
|
|
|
|
if (this.state !== State.Idle) {
|
|
throw new Error('Cant modify splitview');
|
|
}
|
|
|
|
this.state = State.Busy;
|
|
|
|
try {
|
|
const indexes = range(this.viewItems.length).filter(i => i !== index);
|
|
const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index];
|
|
const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);
|
|
|
|
const item = this.viewItems[index];
|
|
size = Math.round(size);
|
|
size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this.size));
|
|
|
|
item.size = size;
|
|
this.relayout(lowPriorityIndexes, highPriorityIndexes);
|
|
} finally {
|
|
this.state = State.Idle;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether all other {@link IView views} are at their minimum size.
|
|
*/
|
|
isViewExpanded(index: number): boolean {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
return false;
|
|
}
|
|
|
|
for (const item of this.viewItems) {
|
|
if (item !== this.viewItems[index] && item.size > item.minimumSize) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Distribute the entire {@link SplitView} size among all {@link IView views}.
|
|
*/
|
|
distributeViewSizes(): void {
|
|
const flexibleViewItems: ViewItem<TLayoutContext, TView>[] = [];
|
|
let flexibleSize = 0;
|
|
|
|
for (const item of this.viewItems) {
|
|
if (item.maximumSize - item.minimumSize > 0) {
|
|
flexibleViewItems.push(item);
|
|
flexibleSize += item.size;
|
|
}
|
|
}
|
|
|
|
const size = Math.floor(flexibleSize / flexibleViewItems.length);
|
|
|
|
for (const item of flexibleViewItems) {
|
|
item.size = clamp(size, item.minimumSize, item.maximumSize);
|
|
}
|
|
|
|
const indexes = range(this.viewItems.length);
|
|
const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
|
|
const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);
|
|
|
|
this.relayout(lowPriorityIndexes, highPriorityIndexes);
|
|
}
|
|
|
|
/**
|
|
* Returns the size of a {@link IView view}.
|
|
*/
|
|
getViewSize(index: number): number {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
return -1;
|
|
}
|
|
|
|
return this.viewItems[index].size;
|
|
}
|
|
|
|
private doAddView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {
|
|
if (this.state !== State.Idle) {
|
|
throw new Error('Cant modify splitview');
|
|
}
|
|
|
|
this.state = State.Busy;
|
|
|
|
try {
|
|
// Add view
|
|
const container = $('.split-view-view');
|
|
|
|
if (index === this.viewItems.length) {
|
|
this.viewContainer.appendChild(container);
|
|
} else {
|
|
this.viewContainer.insertBefore(container, this.viewContainer.children.item(index));
|
|
}
|
|
|
|
const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size));
|
|
const containerDisposable = toDisposable(() => container.remove());
|
|
const disposable = combinedDisposable(onChangeDisposable, containerDisposable);
|
|
|
|
let viewSize: ViewItemSize;
|
|
|
|
if (typeof size === 'number') {
|
|
viewSize = size;
|
|
} else {
|
|
if (size.type === 'auto') {
|
|
if (this.areViewsDistributed()) {
|
|
size = { type: 'distribute' };
|
|
} else {
|
|
size = { type: 'split', index: size.index };
|
|
}
|
|
}
|
|
|
|
if (size.type === 'split') {
|
|
viewSize = this.getViewSize(size.index) / 2;
|
|
} else if (size.type === 'invisible') {
|
|
viewSize = { cachedVisibleSize: size.cachedVisibleSize };
|
|
} else {
|
|
viewSize = view.minimumSize;
|
|
}
|
|
}
|
|
|
|
const item = this.orientation === Orientation.VERTICAL
|
|
? new VerticalViewItem(container, view, viewSize, disposable)
|
|
: new HorizontalViewItem(container, view, viewSize, disposable);
|
|
|
|
this.viewItems.splice(index, 0, item);
|
|
|
|
// Add sash
|
|
if (this.viewItems.length > 1) {
|
|
const opts = { orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash };
|
|
|
|
const sash = this.orientation === Orientation.VERTICAL
|
|
? new Sash(this.sashContainer, { getHorizontalSashTop: s => this.getSashPosition(s), getHorizontalSashWidth: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.HORIZONTAL })
|
|
: new Sash(this.sashContainer, { getVerticalSashLeft: s => this.getSashPosition(s), getVerticalSashHeight: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.VERTICAL });
|
|
|
|
const sashEventMapper = this.orientation === Orientation.VERTICAL
|
|
? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey })
|
|
: (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey });
|
|
|
|
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, () => this.sashItems.findIndex(item => item.sash === sash));
|
|
const onEndDisposable = onEnd(this.onSashEnd, this);
|
|
|
|
const onDidResetDisposable = sash.onDidReset(() => {
|
|
const index = this.sashItems.findIndex(item => item.sash === sash);
|
|
const upIndexes = range(index, -1);
|
|
const downIndexes = range(index + 1, this.viewItems.length);
|
|
const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
|
|
const snapAfterIndex = this.findFirstSnapIndex(downIndexes);
|
|
|
|
if (typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible) {
|
|
return;
|
|
}
|
|
|
|
if (typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible) {
|
|
return;
|
|
}
|
|
|
|
this._onDidSashReset.fire(index);
|
|
});
|
|
|
|
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 highPriorityIndexes: number[] | undefined;
|
|
|
|
if (typeof size !== 'number' && size.type === 'split') {
|
|
highPriorityIndexes = [size.index];
|
|
}
|
|
|
|
if (!skipLayout) {
|
|
this.relayout([index], highPriorityIndexes);
|
|
}
|
|
|
|
|
|
if (!skipLayout && typeof size !== 'number' && size.type === 'distribute') {
|
|
this.distributeViewSizes();
|
|
}
|
|
|
|
} finally {
|
|
this.state = State.Idle;
|
|
}
|
|
}
|
|
|
|
private relayout(lowPriorityIndexes?: number[], highPriorityIndexes?: number[]): void {
|
|
const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
|
|
|
this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes);
|
|
this.distributeEmptySpace();
|
|
this.layoutViews();
|
|
this.saveProportions();
|
|
}
|
|
|
|
private resize(
|
|
index: number,
|
|
delta: number,
|
|
sizes = this.viewItems.map(i => i.size),
|
|
lowPriorityIndexes?: number[],
|
|
highPriorityIndexes?: number[],
|
|
overloadMinDelta: number = Number.NEGATIVE_INFINITY,
|
|
overloadMaxDelta: number = Number.POSITIVE_INFINITY,
|
|
snapBefore?: ISashDragSnapState,
|
|
snapAfter?: ISashDragSnapState
|
|
): number {
|
|
if (index < 0 || index >= this.viewItems.length) {
|
|
return 0;
|
|
}
|
|
|
|
const upIndexes = range(index, -1);
|
|
const downIndexes = range(index + 1, this.viewItems.length);
|
|
|
|
if (highPriorityIndexes) {
|
|
for (const index of highPriorityIndexes) {
|
|
pushToStart(upIndexes, index);
|
|
pushToStart(downIndexes, index);
|
|
}
|
|
}
|
|
|
|
if (lowPriorityIndexes) {
|
|
for (const index of lowPriorityIndexes) {
|
|
pushToEnd(upIndexes, index);
|
|
pushToEnd(downIndexes, index);
|
|
}
|
|
}
|
|
|
|
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].minimumSize - sizes[i]), 0);
|
|
const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].maximumSize - sizes[i]), 0);
|
|
const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);
|
|
const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].maximumSize), 0);
|
|
const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta);
|
|
const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta);
|
|
|
|
let snapped = false;
|
|
|
|
if (snapBefore) {
|
|
const snapView = this.viewItems[snapBefore.index];
|
|
const visible = delta >= snapBefore.limitDelta;
|
|
snapped = visible !== snapView.visible;
|
|
snapView.setVisible(visible, snapBefore.size);
|
|
}
|
|
|
|
if (!snapped && snapAfter) {
|
|
const snapView = this.viewItems[snapAfter.index];
|
|
const visible = delta < snapAfter.limitDelta;
|
|
snapped = visible !== snapView.visible;
|
|
snapView.setVisible(visible, snapAfter.size);
|
|
}
|
|
|
|
if (snapped) {
|
|
return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, 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.minimumSize, item.maximumSize);
|
|
const viewDelta = size - upSizes[i];
|
|
|
|
deltaUp -= viewDelta;
|
|
item.size = size;
|
|
}
|
|
|
|
for (let i = 0, deltaDown = delta; i < downItems.length; i++) {
|
|
const item = downItems[i];
|
|
const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize);
|
|
const viewDelta = size - downSizes[i];
|
|
|
|
deltaDown += viewDelta;
|
|
item.size = size;
|
|
}
|
|
|
|
return delta;
|
|
}
|
|
|
|
private distributeEmptySpace(lowPriorityIndex?: number): void {
|
|
const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
|
let emptyDelta = this.size - contentSize;
|
|
|
|
const indexes = range(this.viewItems.length - 1, -1);
|
|
const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
|
|
const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);
|
|
|
|
for (const index of highPriorityIndexes) {
|
|
pushToStart(indexes, index);
|
|
}
|
|
|
|
for (const index of lowPriorityIndexes) {
|
|
pushToEnd(indexes, index);
|
|
}
|
|
|
|
if (typeof lowPriorityIndex === 'number') {
|
|
pushToEnd(indexes, lowPriorityIndex);
|
|
}
|
|
|
|
for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) {
|
|
const item = this.viewItems[indexes[i]];
|
|
const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize);
|
|
const viewDelta = size - item.size;
|
|
|
|
emptyDelta -= viewDelta;
|
|
item.size = size;
|
|
}
|
|
}
|
|
|
|
private layoutViews(): void {
|
|
// Save new content size
|
|
this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
|
|
|
// Layout views
|
|
let offset = 0;
|
|
|
|
for (const viewItem of this.viewItems) {
|
|
viewItem.layout(offset, this.layoutContext);
|
|
offset += viewItem.size;
|
|
}
|
|
|
|
// Layout sashes
|
|
this.sashItems.forEach(item => item.sash.layout());
|
|
this.updateSashEnablement();
|
|
this.updateScrollableElement();
|
|
}
|
|
|
|
private updateScrollableElement(): void {
|
|
if (this.orientation === Orientation.VERTICAL) {
|
|
this.scrollableElement.setScrollDimensions({
|
|
height: this.size,
|
|
scrollHeight: this._contentSize
|
|
});
|
|
} else {
|
|
this.scrollableElement.setScrollDimensions({
|
|
width: this.size,
|
|
scrollWidth: this._contentSize
|
|
});
|
|
}
|
|
}
|
|
|
|
private updateSashEnablement(): void {
|
|
let previous = false;
|
|
const collapsesDown = this.viewItems.map(i => previous = (i.size - i.minimumSize > 0) || previous);
|
|
|
|
previous = false;
|
|
const expandsDown = this.viewItems.map(i => previous = (i.maximumSize - i.size > 0) || previous);
|
|
|
|
const reverseViews = [...this.viewItems].reverse();
|
|
previous = false;
|
|
const collapsesUp = reverseViews.map(i => previous = (i.size - i.minimumSize > 0) || previous).reverse();
|
|
|
|
previous = false;
|
|
const expandsUp = reverseViews.map(i => previous = (i.maximumSize - i.size > 0) || previous).reverse();
|
|
|
|
let position = 0;
|
|
for (let index = 0; index < this.sashItems.length; index++) {
|
|
const { sash } = this.sashItems[index];
|
|
const viewItem = this.viewItems[index];
|
|
position += viewItem.size;
|
|
|
|
const min = !(collapsesDown[index] && expandsUp[index + 1]);
|
|
const max = !(expandsDown[index] && collapsesUp[index + 1]);
|
|
|
|
if (min && max) {
|
|
const upIndexes = range(index, -1);
|
|
const downIndexes = range(index + 1, this.viewItems.length);
|
|
const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
|
|
const snapAfterIndex = this.findFirstSnapIndex(downIndexes);
|
|
|
|
const snappedBefore = typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible;
|
|
const snappedAfter = typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible;
|
|
|
|
if (snappedBefore && collapsesUp[index] && (position > 0 || this.startSnappingEnabled)) {
|
|
sash.state = SashState.AtMinimum;
|
|
} else if (snappedAfter && collapsesDown[index] && (position < this._contentSize || this.endSnappingEnabled)) {
|
|
sash.state = SashState.AtMaximum;
|
|
} else {
|
|
sash.state = SashState.Disabled;
|
|
}
|
|
} else if (min && !max) {
|
|
sash.state = SashState.AtMinimum;
|
|
} else if (!min && max) {
|
|
sash.state = SashState.AtMaximum;
|
|
} else {
|
|
sash.state = SashState.Enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private findFirstSnapIndex(indexes: number[]): number | undefined {
|
|
// visible views first
|
|
for (const index of indexes) {
|
|
const viewItem = this.viewItems[index];
|
|
|
|
if (!viewItem.visible) {
|
|
continue;
|
|
}
|
|
|
|
if (viewItem.snap) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
// then, hidden views
|
|
for (const index of indexes) {
|
|
const viewItem = this.viewItems[index];
|
|
|
|
if (viewItem.visible && viewItem.maximumSize - viewItem.minimumSize > 0) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!viewItem.visible && viewItem.snap) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private areViewsDistributed() {
|
|
let min = undefined, max = undefined;
|
|
|
|
for (const view of this.viewItems) {
|
|
min = min === undefined ? view.size : Math.min(min, view.size);
|
|
max = max === undefined ? view.size : Math.max(max, view.size);
|
|
|
|
if (max - min > 2) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
override dispose(): void {
|
|
this.sashDragState?.disposable.dispose();
|
|
|
|
dispose(this.viewItems);
|
|
this.viewItems = [];
|
|
|
|
this.sashItems.forEach(i => i.disposable.dispose());
|
|
this.sashItems = [];
|
|
|
|
super.dispose();
|
|
}
|
|
}
|