// 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 { 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 ): 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 { 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(); } } }