@lumino/widgets
Version:
Lumino Widgets
873 lines (744 loc) • 22.9 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 { Layout, LayoutItem } from './layout';
import { Widget } from './widget';
/**
* A layout which arranges its widgets in a grid.
*/
export class GridLayout extends Layout {
/**
* Construct a new grid layout.
*
* @param options - The options for initializing the layout.
*/
constructor(options: GridLayout.IOptions = {}) {
super(options);
if (options.rowCount !== undefined) {
Private.reallocSizers(this._rowSizers, options.rowCount);
}
if (options.columnCount !== undefined) {
Private.reallocSizers(this._columnSizers, options.columnCount);
}
if (options.rowSpacing !== undefined) {
this._rowSpacing = Private.clampValue(options.rowSpacing);
}
if (options.columnSpacing !== undefined) {
this._columnSpacing = Private.clampValue(options.columnSpacing);
}
}
/**
* Dispose of the resources held by the layout.
*/
dispose(): void {
// Dispose of the widgets and layout items.
for (const item of this._items) {
let widget = item.widget;
item.dispose();
widget.dispose();
}
// Clear the layout state.
this._box = null;
this._items.length = 0;
this._rowStarts.length = 0;
this._rowSizers.length = 0;
this._columnStarts.length = 0;
this._columnSizers.length = 0;
// Dispose of the rest of the layout.
super.dispose();
}
/**
* Get the number of rows in the layout.
*/
get rowCount(): number {
return this._rowSizers.length;
}
/**
* Set the number of rows in the layout.
*
* #### Notes
* The minimum row count is `1`.
*/
set rowCount(value: number) {
// Do nothing if the row count does not change.
if (value === this.rowCount) {
return;
}
// Reallocate the row sizers.
Private.reallocSizers(this._rowSizers, value);
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the number of columns in the layout.
*/
get columnCount(): number {
return this._columnSizers.length;
}
/**
* Set the number of columns in the layout.
*
* #### Notes
* The minimum column count is `1`.
*/
set columnCount(value: number) {
// Do nothing if the column count does not change.
if (value === this.columnCount) {
return;
}
// Reallocate the column sizers.
Private.reallocSizers(this._columnSizers, value);
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the row spacing for the layout.
*/
get rowSpacing(): number {
return this._rowSpacing;
}
/**
* Set the row spacing for the layout.
*/
set rowSpacing(value: number) {
// Clamp the spacing to the allowed range.
value = Private.clampValue(value);
// Bail if the spacing does not change
if (this._rowSpacing === value) {
return;
}
// Update the internal spacing.
this._rowSpacing = value;
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the column spacing for the layout.
*/
get columnSpacing(): number {
return this._columnSpacing;
}
/**
* Set the col spacing for the layout.
*/
set columnSpacing(value: number) {
// Clamp the spacing to the allowed range.
value = Private.clampValue(value);
// Bail if the spacing does not change
if (this._columnSpacing === value) {
return;
}
// Update the internal spacing.
this._columnSpacing = value;
// Schedule a fit of the parent.
if (this.parent) {
this.parent.fit();
}
}
/**
* Get the stretch factor for a specific row.
*
* @param index - The row index of interest.
*
* @returns The stretch factor for the row.
*
* #### Notes
* This returns `-1` if the index is out of range.
*/
rowStretch(index: number): number {
let sizer = this._rowSizers[index];
return sizer ? sizer.stretch : -1;
}
/**
* Set the stretch factor for a specific row.
*
* @param index - The row index of interest.
*
* @param value - The stretch factor for the row.
*
* #### Notes
* This is a no-op if the index is out of range.
*/
setRowStretch(index: number, value: number): void {
// Look up the row sizer.
let sizer = this._rowSizers[index];
// Bail if the index is out of range.
if (!sizer) {
return;
}
// Clamp the value to the allowed range.
value = Private.clampValue(value);
// Bail if the stretch does not change.
if (sizer.stretch === value) {
return;
}
// Update the sizer stretch.
sizer.stretch = value;
// Schedule an update of the parent.
if (this.parent) {
this.parent.update();
}
}
/**
* Get the stretch factor for a specific column.
*
* @param index - The column index of interest.
*
* @returns The stretch factor for the column.
*
* #### Notes
* This returns `-1` if the index is out of range.
*/
columnStretch(index: number): number {
let sizer = this._columnSizers[index];
return sizer ? sizer.stretch : -1;
}
/**
* Set the stretch factor for a specific column.
*
* @param index - The column index of interest.
*
* @param value - The stretch factor for the column.
*
* #### Notes
* This is a no-op if the index is out of range.
*/
setColumnStretch(index: number, value: number): void {
// Look up the column sizer.
let sizer = this._columnSizers[index];
// Bail if the index is out of range.
if (!sizer) {
return;
}
// Clamp the value to the allowed range.
value = Private.clampValue(value);
// Bail if the stretch does not change.
if (sizer.stretch === value) {
return;
}
// Update the sizer stretch.
sizer.stretch = value;
// Schedule an update of the parent.
if (this.parent) {
this.parent.update();
}
}
/**
* Create an iterator over the widgets in the layout.
*
* @returns A new iterator over the widgets in the layout.
*/
*[Symbol.iterator](): IterableIterator<Widget> {
for (const item of this._items) {
yield item.widget;
}
}
/**
* Add a widget to the grid layout.
*
* @param widget - The widget to add to the layout.
*
* #### Notes
* If the widget is already contained in the layout, this is no-op.
*/
addWidget(widget: Widget): void {
// Look up the index for the widget.
let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
// Bail if the widget is already in the layout.
if (i !== -1) {
return;
}
// Add the widget to the layout.
this._items.push(new LayoutItem(widget));
// Attach the widget to the parent.
if (this.parent) {
this.attachWidget(widget);
}
}
/**
* Remove a widget from the grid layout.
*
* @param widget - The widget to remove from the layout.
*
* #### Notes
* A widget is automatically removed from the layout when its `parent`
* is set to `null`. This method should only be invoked directly when
* removing a widget from a layout which has yet to be installed on a
* parent widget.
*
* This method does *not* modify the widget's `parent`.
*/
removeWidget(widget: Widget): void {
// Look up the index for the widget.
let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
// Bail if the widget is not in the layout.
if (i === -1) {
return;
}
// Remove the widget from the layout.
let item = ArrayExt.removeAt(this._items, i)!;
// Detach the widget from the parent.
if (this.parent) {
this.detachWidget(widget);
}
// Dispose the layout item.
item.dispose();
}
/**
* Perform layout initialization which requires the parent widget.
*/
protected init(): void {
super.init();
for (const widget of this) {
this.attachWidget(widget);
}
}
/**
* Attach a widget to the parent's DOM node.
*
* @param widget - The widget to attach to the parent.
*/
protected attachWidget(widget: Widget): void {
// Send a `'before-attach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
}
// Add the widget's node to the parent.
this.parent!.node.appendChild(widget.node);
// 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();
}
/**
* Detach a widget from the parent's DOM node.
*
* @param widget - The widget to detach from the parent.
*/
protected detachWidget(widget: Widget): void {
// Send a `'before-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
}
// Remove the widget's node from the parent.
this.parent!.node.removeChild(widget.node);
// Send an `'after-detach'` message if the parent is attached.
if (this.parent!.isAttached) {
MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
}
// 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();
}
}
/**
* Fit the layout to the total size required by the widgets.
*/
private _fit(): void {
// Reset the min sizes of the sizers.
for (let i = 0, n = this.rowCount; i < n; ++i) {
this._rowSizers[i].minSize = 0;
}
for (let i = 0, n = this.columnCount; i < n; ++i) {
this._columnSizers[i].minSize = 0;
}
// Filter for the visible layout items.
let items = this._items.filter(it => !it.isHidden);
// Fit the layout items.
for (let i = 0, n = items.length; i < n; ++i) {
items[i].fit();
}
// Get the max row and column index.
let maxRow = this.rowCount - 1;
let maxCol = this.columnCount - 1;
// Sort the items by row span.
items.sort(Private.rowSpanCmp);
// Update the min sizes of the row sizers.
for (let i = 0, n = items.length; i < n; ++i) {
// Fetch the item.
let item = items[i];
// Get the row bounds for the item.
let config = GridLayout.getCellConfig(item.widget);
let r1 = Math.min(config.row, maxRow);
let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
// Distribute the minimum height to the sizers as needed.
Private.distributeMin(this._rowSizers, r1, r2, item.minHeight);
}
// Sort the items by column span.
items.sort(Private.columnSpanCmp);
// Update the min sizes of the column sizers.
for (let i = 0, n = items.length; i < n; ++i) {
// Fetch the item.
let item = items[i];
// Get the column bounds for the item.
let config = GridLayout.getCellConfig(item.widget);
let c1 = Math.min(config.column, maxCol);
let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
// Distribute the minimum width to the sizers as needed.
Private.distributeMin(this._columnSizers, c1, c2, item.minWidth);
}
// If no size constraint is needed, just update the parent.
if (this.fitPolicy === 'set-no-constraint') {
MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
return;
}
// Set up the computed min size.
let minH = maxRow * this._rowSpacing;
let minW = maxCol * this._columnSpacing;
// Add the sizer minimums to the computed min size.
for (let i = 0, n = this.rowCount; i < n; ++i) {
minH += this._rowSizers[i].minSize;
}
for (let i = 0, n = this.columnCount; i < n; ++i) {
minW += this._columnSizers[i].minSize;
}
// 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;
// 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 layout area 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;
// Get the max row and column index.
let maxRow = this.rowCount - 1;
let maxCol = this.columnCount - 1;
// Compute the total fixed row and column space.
let fixedRowSpace = maxRow * this._rowSpacing;
let fixedColSpace = maxCol * this._columnSpacing;
// Distribute the available space to the box sizers.
BoxEngine.calc(this._rowSizers, Math.max(0, height - fixedRowSpace));
BoxEngine.calc(this._columnSizers, Math.max(0, width - fixedColSpace));
// Update the row start positions.
for (let i = 0, pos = top, n = this.rowCount; i < n; ++i) {
this._rowStarts[i] = pos;
pos += this._rowSizers[i].size + this._rowSpacing;
}
// Update the column start positions.
for (let i = 0, pos = left, n = this.columnCount; i < n; ++i) {
this._columnStarts[i] = pos;
pos += this._columnSizers[i].size + this._columnSpacing;
}
// Update the geometry of the layout items.
for (let i = 0, n = this._items.length; i < n; ++i) {
// Fetch the item.
let item = this._items[i];
// Ignore hidden items.
if (item.isHidden) {
continue;
}
// Fetch the cell bounds for the widget.
let config = GridLayout.getCellConfig(item.widget);
let r1 = Math.min(config.row, maxRow);
let c1 = Math.min(config.column, maxCol);
let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
// Compute the cell geometry.
let x = this._columnStarts[c1];
let y = this._rowStarts[r1];
let w = this._columnStarts[c2] + this._columnSizers[c2].size - x;
let h = this._rowStarts[r2] + this._rowSizers[r2].size - y;
// Update the geometry of the layout item.
item.update(x, y, w, h);
}
}
private _dirty = false;
private _rowSpacing = 4;
private _columnSpacing = 4;
private _items: LayoutItem[] = [];
private _rowStarts: number[] = [];
private _columnStarts: number[] = [];
private _rowSizers: BoxSizer[] = [new BoxSizer()];
private _columnSizers: BoxSizer[] = [new BoxSizer()];
private _box: ElementExt.IBoxSizing | null = null;
}
/**
* The namespace for the `GridLayout` class statics.
*/
export namespace GridLayout {
/**
* An options object for initializing a grid layout.
*/
export interface IOptions extends Layout.IOptions {
/**
* The initial row count for the layout.
*
* The default is `1`.
*/
rowCount?: number;
/**
* The initial column count for the layout.
*
* The default is `1`.
*/
columnCount?: number;
/**
* The spacing between rows in the layout.
*
* The default is `4`.
*/
rowSpacing?: number;
/**
* The spacing between columns in the layout.
*
* The default is `4`.
*/
columnSpacing?: number;
}
/**
* An object which holds the cell configuration for a widget.
*/
export interface ICellConfig {
/**
* The row index for the widget.
*/
readonly row: number;
/**
* The column index for the widget.
*/
readonly column: number;
/**
* The row span for the widget.
*/
readonly rowSpan: number;
/**
* The column span for the widget.
*/
readonly columnSpan: number;
}
/**
* Get the cell config for the given widget.
*
* @param widget - The widget of interest.
*
* @returns The cell config for the widget.
*/
export function getCellConfig(widget: Widget): ICellConfig {
return Private.cellConfigProperty.get(widget);
}
/**
* Set the cell config for the given widget.
*
* @param widget - The widget of interest.
*
* @param value - The value for the cell config.
*/
export function setCellConfig(
widget: Widget,
value: Partial<ICellConfig>
): void {
Private.cellConfigProperty.set(widget, Private.normalizeConfig(value));
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* The property descriptor for the widget cell config.
*/
export const cellConfigProperty = new AttachedProperty<
Widget,
GridLayout.ICellConfig
>({
name: 'cellConfig',
create: () => ({ row: 0, column: 0, rowSpan: 1, columnSpan: 1 }),
changed: onChildCellConfigChanged
});
/**
* Normalize a partial cell config object.
*/
export function normalizeConfig(
config: Partial<GridLayout.ICellConfig>
): GridLayout.ICellConfig {
let row = Math.max(0, Math.floor(config.row || 0));
let column = Math.max(0, Math.floor(config.column || 0));
let rowSpan = Math.max(1, Math.floor(config.rowSpan || 0));
let columnSpan = Math.max(1, Math.floor(config.columnSpan || 0));
return { row, column, rowSpan, columnSpan };
}
/**
* Clamp a value to an integer >= 0.
*/
export function clampValue(value: number): number {
return Math.max(0, Math.floor(value));
}
/**
* A sort comparison function for row spans.
*/
export function rowSpanCmp(a: LayoutItem, b: LayoutItem): number {
let c1 = cellConfigProperty.get(a.widget);
let c2 = cellConfigProperty.get(b.widget);
return c1.rowSpan - c2.rowSpan;
}
/**
* A sort comparison function for column spans.
*/
export function columnSpanCmp(a: LayoutItem, b: LayoutItem): number {
let c1 = cellConfigProperty.get(a.widget);
let c2 = cellConfigProperty.get(b.widget);
return c1.columnSpan - c2.columnSpan;
}
/**
* Reallocate the box sizers for the given grid dimensions.
*/
export function reallocSizers(sizers: BoxSizer[], count: number): void {
// Coerce the count to the valid range.
count = Math.max(1, Math.floor(count));
// Add the missing sizers.
while (sizers.length < count) {
sizers.push(new BoxSizer());
}
// Remove the extra sizers.
if (sizers.length > count) {
sizers.length = count;
}
}
/**
* Distribute a min size constraint across a range of sizers.
*/
export function distributeMin(
sizers: BoxSizer[],
i1: number,
i2: number,
minSize: number
): void {
// Sanity check the indices.
if (i2 < i1) {
return;
}
// Handle the simple case of no cell span.
if (i1 === i2) {
let sizer = sizers[i1];
sizer.minSize = Math.max(sizer.minSize, minSize);
return;
}
// Compute the total current min size of the span.
let totalMin = 0;
for (let i = i1; i <= i2; ++i) {
totalMin += sizers[i].minSize;
}
// Do nothing if the total is greater than the required.
if (totalMin >= minSize) {
return;
}
// Compute the portion of the space to allocate to each sizer.
let portion = (minSize - totalMin) / (i2 - i1 + 1);
// Add the portion to each sizer.
for (let i = i1; i <= i2; ++i) {
sizers[i].minSize += portion;
}
}
/**
* The change handler for the child cell config property.
*/
function onChildCellConfigChanged(child: Widget): void {
if (child.parent && child.parent.layout instanceof GridLayout) {
child.parent.fit();
}
}
}