@lumino/widgets
Version:
Lumino Widgets
869 lines (763 loc) • 23.6 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { ArrayExt } from '@lumino/algorithm';
import { ElementExt } from '@lumino/domutils';
import { Message, MessageLoop } from '@lumino/messaging';
import { AttachedProperty } from '@lumino/properties';
import { BoxEngine, BoxSizer } from './boxengine';
import { LayoutItem } from './layout';
import { PanelLayout } from './panellayout';
import { Utils } from './utils';
import { Widget } from './widget';
/**
* A layout which arranges its widgets into resizable sections.
*/
export class SplitLayout extends PanelLayout {
/**
* Construct a new split layout.
*
* @param options - The options for initializing the layout.
*/
constructor(options: SplitLayout.IOptions) {
super();
this.renderer = options.renderer;
if (options.orientation !== undefined) {
this._orientation = options.orientation;
}
if (options.alignment !== undefined) {
this._alignment = options.alignment;
}
if (options.spacing !== undefined) {
this._spacing = Utils.clampDimension(options.spacing);
}
}
/**
* Dispose of the resources held by the layout.
*/
dispose(): void {
// Dispose of the layout items.
for (const item of this._items) {
item.dispose();
}
// Clear the layout state.
this._box = null;
this._items.length = 0;
this._sizers.length = 0;
this._handles.length = 0;
// Dispose of the rest of the layout.
super.dispose();
}
/**
* The renderer used by the split layout.
*/
readonly renderer: SplitLayout.IRenderer;
/**
* Get the layout orientation for the split layout.
*/
get orientation(): SplitLayout.Orientation {
return this._orientation;
}
/**
* Set the layout orientation for the split layout.
*/
set orientation(value: SplitLayout.Orientation) {
if (this._orientation === value) {
return;
}
this._orientation = value;
if (!this.parent) {
return;
}
this.parent.dataset['orientation'] = value;
this.parent.fit();
}
/**
* Get the content alignment for the split layout.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire split layout.
*/
get alignment(): SplitLayout.Alignment {
return this._alignment;
}
/**
* Set the content alignment for the split layout.
*
* #### Notes
* This is the alignment of the widgets in the layout direction.
*
* The alignment has no effect if the widgets can expand to fill the
* entire split layout.
*/
set alignment(value: SplitLayout.Alignment) {
if (this._alignment === value) {
return;
}
this._alignment = value;
if (!this.parent) {
return;
}
this.parent.dataset['alignment'] = value;
this.parent.update();
}
/**
* Get the inter-element spacing for the split layout.
*/
get spacing(): number {
return this._spacing;
}
/**
* Set the inter-element spacing for the split layout.
*/
set spacing(value: number) {
value = Utils.clampDimension(value);
if (this._spacing === value) {
return;
}
this._spacing = value;
if (!this.parent) {
return;
}
this.parent.fit();
}
/**
* A read-only array of the split handles in the layout.
*/
get handles(): ReadonlyArray<HTMLDivElement> {
return this._handles;
}
/**
* Get the absolute sizes of the widgets in the layout.
*
* @returns A new array of the absolute sizes of the widgets.
*
* This method **does not** measure the DOM nodes.
*/
absoluteSizes(): number[] {
return this._sizers.map(sizer => sizer.size);
}
/**
* Get the relative sizes of the widgets in the layout.
*
* @returns A new array of the relative sizes of the widgets.
*
* #### Notes
* The returned sizes reflect the sizes of the widgets normalized
* relative to their siblings.
*
* This method **does not** measure the DOM nodes.
*/
relativeSizes(): number[] {
return Private.normalize(this._sizers.map(sizer => sizer.size));
}
/**
* Set the relative sizes for the widgets in the layout.
*
* @param sizes - The relative sizes for the widgets in the panel.
* @param update - Update the layout after setting relative sizes.
* Default is True.
*
* #### Notes
* Extra values are ignored, too few will yield an undefined layout.
*
* The actual geometry of the DOM nodes is updated asynchronously.
*/
setRelativeSizes(sizes: number[], update = true): void {
// Copy the sizes and pad with zeros as needed.
let n = this._sizers.length;
let temp = sizes.slice(0, n);
while (temp.length < n) {
temp.push(0);
}
// Normalize the padded sizes.
let normed = Private.normalize(temp);
// Apply the normalized sizes to the sizers.
for (let i = 0; i < n; ++i) {
let sizer = this._sizers[i];
sizer.sizeHint = normed[i];
sizer.size = normed[i];
}
// Set the flag indicating the sizes are normalized.
this._hasNormedSizes = true;
// Trigger an update of the parent widget.
if (update && this.parent) {
this.parent.update();
}
}
/**
* Move the offset position of a split handle.
*
* @param index - The index of the handle of the interest.
*
* @param position - The desired offset position of the handle.
*
* #### Notes
* The position is relative to the offset parent.
*
* This will move the handle as close as possible to the desired
* position. The sibling widgets will be adjusted as necessary.
*/
moveHandle(index: number, position: number): void {
// Bail if the index is invalid or the handle is hidden.
let handle = this._handles[index];
if (!handle || handle.classList.contains('lm-mod-hidden')) {
return;
}
// Compute the desired delta movement for the handle.
let delta: number;
if (this._orientation === 'horizontal') {
delta = position - handle.offsetLeft;
} else {
delta = position - handle.offsetTop;
}
// Bail if there is no handle movement.
if (delta === 0) {
return;
}
// Prevent widget resizing unless needed.
for (let sizer of this._sizers) {
if (sizer.size > 0) {
sizer.sizeHint = sizer.size;
}
}
// Adjust the sizers to reflect the handle movement.
BoxEngine.adjust(this._sizers, index, delta);
// Update the layout of the widgets.
if (this.parent) {
this.parent.update();
}
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
this.parent!.dataset['orientation'] = this.orientation;
this.parent!.dataset['alignment'] = this.alignment;
super.init();
}
/**
* Attach a widget to the parent's DOM node.
*
* @param index - The current index of the widget in the layout.
*
* @param widget - The widget to attach to the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected attachWidget(index: number, widget: Widget): void {
// Create the item, handle, and sizer for the new widget.
let item = new LayoutItem(widget);
let handle = Private.createHandle(this.renderer);
let average = Private.averageSize(this._sizers);
let sizer = Private.createSizer(average);
// Insert the item, handle, and sizer into the internal arrays.
ArrayExt.insert(this._items, index, item);
ArrayExt.insert(this._sizers, index, sizer);
ArrayExt.insert(this._handles, index, handle);
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Add the widget and handle nodes to the parent.
this.parent!.node.appendChild(widget.node);
this.parent!.node.appendChild(handle);
// Send an `'after-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
}
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* Move a widget in the parent's DOM node.
*
* @param fromIndex - The previous index of the widget in the layout.
*
* @param toIndex - The current index of the widget in the layout.
*
* @param widget - The widget to move in the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected moveWidget(
fromIndex: number,
toIndex: number,
widget: Widget
): void {
// Move the item, sizer, and handle for the widget.
ArrayExt.move(this._items, fromIndex, toIndex);
ArrayExt.move(this._sizers, fromIndex, toIndex);
ArrayExt.move(this._handles, fromIndex, toIndex);
// Post a fit request to the parent to show/hide last handle.
this.parent!.fit();
}
/**
* Detach a widget from the parent's DOM node.
*
* @param index - The previous index of the widget in the layout.
*
* @param widget - The widget to detach from the parent.
*
* #### Notes
* This is a reimplementation of the superclass method.
*/
protected detachWidget(index: number, widget: Widget): void {
// Remove the item, handle, and sizer for the widget.
let item = ArrayExt.removeAt(this._items, index);
let handle = ArrayExt.removeAt(this._handles, index);
ArrayExt.removeAt(this._sizers, index);
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget and handle nodes from the parent.
this.parent!.node.removeChild(widget.node);
this.parent!.node.removeChild(handle!);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
// Dispose of the layout item.
item!.dispose();
// Post a fit request for the parent widget.
this.parent!.fit();
}
/**
* A message handler invoked on a `'before-show'` message.
*/
protected onBeforeShow(msg: Message): void {
super.onBeforeShow(msg);
this.parent!.update();
}
/**
* A message handler invoked on a `'before-attach'` message.
*/
protected onBeforeAttach(msg: Message): void {
super.onBeforeAttach(msg);
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-shown'` message.
*/
protected onChildShown(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'child-hidden'` message.
*/
protected onChildHidden(msg: Widget.ChildMessage): void {
this.parent!.fit();
}
/**
* A message handler invoked on a `'resize'` message.
*/
protected onResize(msg: Widget.ResizeMessage): void {
if (this.parent!.isVisible) {
this._update(msg.width, msg.height);
}
}
/**
* A message handler invoked on an `'update-request'` message.
*/
protected onUpdateRequest(msg: Message): void {
if (this.parent!.isVisible) {
this._update(-1, -1);
}
}
/**
* A message handler invoked on a `'fit-request'` message.
*/
protected onFitRequest(msg: Message): void {
if (this.parent!.isAttached) {
this._fit();
}
}
/**
* Update the item position.
*
* @param i Item index
* @param isHorizontal Whether the layout is horizontal or not
* @param left Left position in pixels
* @param top Top position in pixels
* @param height Item height
* @param width Item width
* @param size Item size
*/
protected updateItemPosition(
i: number,
isHorizontal: boolean,
left: number,
top: number,
height: number,
width: number,
size: number
): void {
const item = this._items[i];
if (item.isHidden) {
return;
}
// Fetch the style for the handle.
let handleStyle = this._handles[i].style;
// Update the widget and handle, and advance the relevant edge.
if (isHorizontal) {
left += this.widgetOffset;
item.update(left, top, size, height);
left += size;
handleStyle.top = `${top}px`;
handleStyle.left = `${left}px`;
handleStyle.width = `${this._spacing}px`;
handleStyle.height = `${height}px`;
} else {
top += this.widgetOffset;
item.update(left, top, width, size);
top += size;
handleStyle.top = `${top}px`;
handleStyle.left = `${left}px`;
handleStyle.width = `${width}px`;
handleStyle.height = `${this._spacing}px`;
}
}
/**
* Fit the layout to the total size required by the widgets.
*/
private _fit(): void {
// Update the handles and track the visible widget count.
let nVisible = 0;
let lastHandleIndex = -1;
for (let i = 0, n = this._items.length; i < n; ++i) {
if (this._items[i].isHidden) {
this._handles[i].classList.add('lm-mod-hidden');
} else {
this._handles[i].classList.remove('lm-mod-hidden');
lastHandleIndex = i;
nVisible++;
}
}
// Hide the handle for the last visible widget.
if (lastHandleIndex !== -1) {
this._handles[lastHandleIndex].classList.add('lm-mod-hidden');
}
// Update the fixed space for the visible items.
this._fixed =
this._spacing * Math.max(0, nVisible - 1) +
this.widgetOffset * this._items.length;
// Setup the computed minimum size.
let horz = this._orientation === 'horizontal';
let minW = horz ? this._fixed : 0;
let minH = horz ? 0 : this._fixed;
// Update the sizers and computed size limits.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item and corresponding box sizer.
let item = this._items[i];
let sizer = this._sizers[i];
// Prevent resizing unless necessary.
if (sizer.size > 0) {
sizer.sizeHint = sizer.size;
}
// If the item is hidden, it should consume zero size.
if (item.isHidden) {
sizer.minSize = 0;
sizer.maxSize = 0;
continue;
}
// Update the size limits for the item.
item.fit();
// Update the stretch factor.
sizer.stretch = SplitLayout.getStretch(item.widget);
// Update the sizer limits and computed min size.
if (horz) {
sizer.minSize = item.minWidth;
sizer.maxSize = item.maxWidth;
minW += item.minWidth;
minH = Math.max(minH, item.minHeight);
} else {
sizer.minSize = item.minHeight;
sizer.maxSize = item.maxHeight;
minH += item.minHeight;
minW = Math.max(minW, item.minWidth);
}
}
// Update the box sizing and add it to the computed min size.
let box = (this._box = ElementExt.boxSizing(this.parent!.node));
minW += box.horizontalSum;
minH += box.verticalSum;
// Update the parent's min size constraints.
let style = this.parent!.node.style;
style.minWidth = `${minW}px`;
style.minHeight = `${minH}px`;
// Set the dirty flag to ensure only a single update occurs.
this._dirty = true;
// Notify the ancestor that it should fit immediately. This may
// cause a resize of the parent, fulfilling the required update.
if (this.parent!.parent) {
MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
}
// If the dirty flag is still set, the parent was not resized.
// Trigger the required update on the parent widget immediately.
if (this._dirty) {
MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
}
}
/**
* Update the layout position and size of the widgets.
*
* The parent offset dimensions should be `-1` if unknown.
*/
private _update(offsetWidth: number, offsetHeight: number): void {
// Clear the dirty flag to indicate the update occurred.
this._dirty = false;
// Compute the visible item count.
let nVisible = 0;
for (let i = 0, n = this._items.length; i < n; ++i) {
nVisible += +!this._items[i].isHidden;
}
// Bail early if there are no visible items to layout.
if (nVisible === 0 && this.widgetOffset === 0) {
return;
}
// Measure the parent if the offset dimensions are unknown.
if (offsetWidth < 0) {
offsetWidth = this.parent!.node.offsetWidth;
}
if (offsetHeight < 0) {
offsetHeight = this.parent!.node.offsetHeight;
}
// Ensure the parent box sizing data is computed.
if (!this._box) {
this._box = ElementExt.boxSizing(this.parent!.node);
}
// Compute the actual layout bounds adjusted for border and padding.
let top = this._box.paddingTop;
let left = this._box.paddingLeft;
let width = offsetWidth - this._box.horizontalSum;
let height = offsetHeight - this._box.verticalSum;
// Set up the variables for justification and alignment offset.
let extra = 0;
let offset = 0;
let horz = this._orientation === 'horizontal';
if (nVisible > 0) {
// Compute the adjusted layout space.
let space: number;
if (horz) {
// left += this.widgetOffset;
space = Math.max(0, width - this._fixed);
} else {
// top += this.widgetOffset;
space = Math.max(0, height - this._fixed);
}
// Scale the size hints if they are normalized.
if (this._hasNormedSizes) {
for (let sizer of this._sizers) {
sizer.sizeHint *= space;
}
this._hasNormedSizes = false;
}
// Distribute the layout space to the box sizers.
let delta = BoxEngine.calc(this._sizers, space);
// Account for alignment if there is extra layout space.
if (delta > 0) {
switch (this._alignment) {
case 'start':
break;
case 'center':
extra = 0;
offset = delta / 2;
break;
case 'end':
extra = 0;
offset = delta;
break;
case 'justify':
extra = delta / nVisible;
offset = 0;
break;
default:
throw 'unreachable';
}
}
}
// Layout the items using the computed box sizes.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item.
const item = this._items[i];
// Fetch the computed size for the widget.
const size = item.isHidden ? 0 : this._sizers[i].size + extra;
this.updateItemPosition(
i,
horz,
horz ? left + offset : left,
horz ? top : top + offset,
height,
width,
size
);
const fullOffset =
this.widgetOffset +
(this._handles[i].classList.contains('lm-mod-hidden')
? 0
: this._spacing);
if (horz) {
left += size + fullOffset;
} else {
top += size + fullOffset;
}
}
}
protected widgetOffset = 0;
private _fixed = 0;
private _spacing = 4;
private _dirty = false;
private _hasNormedSizes = false;
private _sizers: BoxSizer[] = [];
private _items: LayoutItem[] = [];
private _handles: HTMLDivElement[] = [];
private _box: ElementExt.IBoxSizing | null = null;
private _alignment: SplitLayout.Alignment = 'start';
private _orientation: SplitLayout.Orientation = 'horizontal';
}
/**
* The namespace for the `SplitLayout` class statics.
*/
export namespace SplitLayout {
/**
* A type alias for a split layout orientation.
*/
export type Orientation = 'horizontal' | 'vertical';
/**
* A type alias for a split layout alignment.
*/
export type Alignment = 'start' | 'center' | 'end' | 'justify';
/**
* An options object for initializing a split layout.
*/
export interface IOptions {
/**
* The renderer to use for the split layout.
*/
renderer: IRenderer;
/**
* The orientation of the layout.
*
* Possible values are documented in {@link SplitLayout.Orientation}.
*
* The default is `'horizontal'`.
*/
orientation?: Orientation;
/**
* The content alignment of the layout.
*
* Possible values are documented in {@link SplitLayout.Alignment}.
*
* The default is `'start'`.
*/
alignment?: Alignment;
/**
* The spacing between items in the layout.
*
* The default is `4`.
*/
spacing?: number;
}
/**
* A renderer for use with a split layout.
*/
export interface IRenderer {
/**
* Create a new handle for use with a split layout.
*
* @returns A new handle element.
*/
createHandle(): HTMLDivElement;
}
/**
* Get the split layout stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The split layout stretch factor for the widget.
*/
export function getStretch(widget: Widget): number {
return Private.stretchProperty.get(widget);
}
/**
* Set the split layout stretch factor for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the stretch factor.
*/
export function setStretch(widget: Widget, value: number): void {
Private.stretchProperty.set(widget, value);
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The property descriptor for a widget stretch factor.
*/
export const stretchProperty = new AttachedProperty<Widget, number>({
name: 'stretch',
create: () => 0,
coerce: (owner, value) => Math.max(0, Math.floor(value)),
changed: onChildSizingChanged
});
/**
* Create a new box sizer with the given size hint.
*/
export function createSizer(size: number): BoxSizer {
let sizer = new BoxSizer();
sizer.sizeHint = Math.floor(size);
return sizer;
}
/**
* Create a new split handle node using the given renderer.
*/
export function createHandle(
renderer: SplitLayout.IRenderer
): HTMLDivElement {
let handle = renderer.createHandle();
handle.style.position = 'absolute';
// Do not use size containment to allow the handle to fill the available space
handle.style.contain = 'style';
return handle;
}
/**
* Compute the average size of an array of box sizers.
*/
export function averageSize(sizers: BoxSizer[]): number {
return sizers.reduce((v, s) => v + s.size, 0) / sizers.length || 0;
}
/**
* Normalize an array of values.
*/
export function normalize(values: number[]): number[] {
let n = values.length;
if (n === 0) {
return [];
}
let sum = values.reduce((a, b) => a + Math.abs(b), 0);
return sum === 0 ? values.map(v => 1 / n) : values.map(v => v / sum);
}
/**
* The change handler for the attached sizing properties.
*/
function onChildSizingChanged(child: Widget): void {
if (child.parent && child.parent.layout instanceof SplitLayout) {
child.parent.fit();
}
}
}