UNPKG

22.9 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3/*-----------------------------------------------------------------------------
4| Copyright (c) 2014-2017, PhosphorJS Contributors
5|
6| Distributed under the terms of the BSD 3-Clause License.
7|
8| The full license is in the file LICENSE, distributed with this software.
9|----------------------------------------------------------------------------*/
10import { ArrayExt, each, IIterator, map } from '@lumino/algorithm';
11
12import { ElementExt } from '@lumino/domutils';
13
14import { Message, MessageLoop } from '@lumino/messaging';
15
16import { AttachedProperty } from '@lumino/properties';
17
18import { BoxEngine, BoxSizer } from './boxengine';
19
20import { Layout, LayoutItem } from './layout';
21
22import { Widget } from './widget';
23
24/**
25 * A layout which arranges its widgets in a grid.
26 */
27export class GridLayout extends Layout {
28 /**
29 * Construct a new grid layout.
30 *
31 * @param options - The options for initializing the layout.
32 */
33 constructor(options: GridLayout.IOptions = {}) {
34 super(options);
35 if (options.rowCount !== undefined) {
36 Private.reallocSizers(this._rowSizers, options.rowCount);
37 }
38 if (options.columnCount !== undefined) {
39 Private.reallocSizers(this._columnSizers, options.columnCount);
40 }
41 if (options.rowSpacing !== undefined) {
42 this._rowSpacing = Private.clampValue(options.rowSpacing);
43 }
44 if (options.columnSpacing !== undefined) {
45 this._columnSpacing = Private.clampValue(options.columnSpacing);
46 }
47 }
48
49 /**
50 * Dispose of the resources held by the layout.
51 */
52 dispose(): void {
53 // Dispose of the widgets and layout items.
54 each(this._items, item => {
55 let widget = item.widget;
56 item.dispose();
57 widget.dispose();
58 });
59
60 // Clear the layout state.
61 this._box = null;
62 this._items.length = 0;
63 this._rowStarts.length = 0;
64 this._rowSizers.length = 0;
65 this._columnStarts.length = 0;
66 this._columnSizers.length = 0;
67
68 // Dispose of the rest of the layout.
69 super.dispose();
70 }
71
72 /**
73 * Get the number of rows in the layout.
74 */
75 get rowCount(): number {
76 return this._rowSizers.length;
77 }
78
79 /**
80 * Set the number of rows in the layout.
81 *
82 * #### Notes
83 * The minimum row count is `1`.
84 */
85 set rowCount(value: number) {
86 // Do nothing if the row count does not change.
87 if (value === this.rowCount) {
88 return;
89 }
90
91 // Reallocate the row sizers.
92 Private.reallocSizers(this._rowSizers, value);
93
94 // Schedule a fit of the parent.
95 if (this.parent) {
96 this.parent.fit();
97 }
98 }
99
100 /**
101 * Get the number of columns in the layout.
102 */
103 get columnCount(): number {
104 return this._columnSizers.length;
105 }
106
107 /**
108 * Set the number of columns in the layout.
109 *
110 * #### Notes
111 * The minimum column count is `1`.
112 */
113 set columnCount(value: number) {
114 // Do nothing if the column count does not change.
115 if (value === this.columnCount) {
116 return;
117 }
118
119 // Reallocate the column sizers.
120 Private.reallocSizers(this._columnSizers, value);
121
122 // Schedule a fit of the parent.
123 if (this.parent) {
124 this.parent.fit();
125 }
126 }
127
128 /**
129 * Get the row spacing for the layout.
130 */
131 get rowSpacing(): number {
132 return this._rowSpacing;
133 }
134
135 /**
136 * Set the row spacing for the layout.
137 */
138 set rowSpacing(value: number) {
139 // Clamp the spacing to the allowed range.
140 value = Private.clampValue(value);
141
142 // Bail if the spacing does not change
143 if (this._rowSpacing === value) {
144 return;
145 }
146
147 // Update the internal spacing.
148 this._rowSpacing = value;
149
150 // Schedule a fit of the parent.
151 if (this.parent) {
152 this.parent.fit();
153 }
154 }
155
156 /**
157 * Get the column spacing for the layout.
158 */
159 get columnSpacing(): number {
160 return this._columnSpacing;
161 }
162
163 /**
164 * Set the col spacing for the layout.
165 */
166 set columnSpacing(value: number) {
167 // Clamp the spacing to the allowed range.
168 value = Private.clampValue(value);
169
170 // Bail if the spacing does not change
171 if (this._columnSpacing === value) {
172 return;
173 }
174
175 // Update the internal spacing.
176 this._columnSpacing = value;
177
178 // Schedule a fit of the parent.
179 if (this.parent) {
180 this.parent.fit();
181 }
182 }
183
184 /**
185 * Get the stretch factor for a specific row.
186 *
187 * @param index - The row index of interest.
188 *
189 * @returns The stretch factor for the row.
190 *
191 * #### Notes
192 * This returns `-1` if the index is out of range.
193 */
194 rowStretch(index: number): number {
195 let sizer = this._rowSizers[index];
196 return sizer ? sizer.stretch : -1;
197 }
198
199 /**
200 * Set the stretch factor for a specific row.
201 *
202 * @param index - The row index of interest.
203 *
204 * @param value - The stretch factor for the row.
205 *
206 * #### Notes
207 * This is a no-op if the index is out of range.
208 */
209 setRowStretch(index: number, value: number): void {
210 // Look up the row sizer.
211 let sizer = this._rowSizers[index];
212
213 // Bail if the index is out of range.
214 if (!sizer) {
215 return;
216 }
217
218 // Clamp the value to the allowed range.
219 value = Private.clampValue(value);
220
221 // Bail if the stretch does not change.
222 if (sizer.stretch === value) {
223 return;
224 }
225
226 // Update the sizer stretch.
227 sizer.stretch = value;
228
229 // Schedule an update of the parent.
230 if (this.parent) {
231 this.parent.update();
232 }
233 }
234
235 /**
236 * Get the stretch factor for a specific column.
237 *
238 * @param index - The column index of interest.
239 *
240 * @returns The stretch factor for the column.
241 *
242 * #### Notes
243 * This returns `-1` if the index is out of range.
244 */
245 columnStretch(index: number): number {
246 let sizer = this._columnSizers[index];
247 return sizer ? sizer.stretch : -1;
248 }
249
250 /**
251 * Set the stretch factor for a specific column.
252 *
253 * @param index - The column index of interest.
254 *
255 * @param value - The stretch factor for the column.
256 *
257 * #### Notes
258 * This is a no-op if the index is out of range.
259 */
260 setColumnStretch(index: number, value: number): void {
261 // Look up the column sizer.
262 let sizer = this._columnSizers[index];
263
264 // Bail if the index is out of range.
265 if (!sizer) {
266 return;
267 }
268
269 // Clamp the value to the allowed range.
270 value = Private.clampValue(value);
271
272 // Bail if the stretch does not change.
273 if (sizer.stretch === value) {
274 return;
275 }
276
277 // Update the sizer stretch.
278 sizer.stretch = value;
279
280 // Schedule an update of the parent.
281 if (this.parent) {
282 this.parent.update();
283 }
284 }
285
286 /**
287 * Create an iterator over the widgets in the layout.
288 *
289 * @returns A new iterator over the widgets in the layout.
290 */
291 iter(): IIterator<Widget> {
292 return map(this._items, item => item.widget);
293 }
294
295 /**
296 * Add a widget to the grid layout.
297 *
298 * @param widget - The widget to add to the layout.
299 *
300 * #### Notes
301 * If the widget is already contained in the layout, this is no-op.
302 */
303 addWidget(widget: Widget): void {
304 // Look up the index for the widget.
305 let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
306
307 // Bail if the widget is already in the layout.
308 if (i !== -1) {
309 return;
310 }
311
312 // Add the widget to the layout.
313 this._items.push(new LayoutItem(widget));
314
315 // Attach the widget to the parent.
316 if (this.parent) {
317 this.attachWidget(widget);
318 }
319 }
320
321 /**
322 * Remove a widget from the grid layout.
323 *
324 * @param widget - The widget to remove from the layout.
325 *
326 * #### Notes
327 * A widget is automatically removed from the layout when its `parent`
328 * is set to `null`. This method should only be invoked directly when
329 * removing a widget from a layout which has yet to be installed on a
330 * parent widget.
331 *
332 * This method does *not* modify the widget's `parent`.
333 */
334 removeWidget(widget: Widget): void {
335 // Look up the index for the widget.
336 let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);
337
338 // Bail if the widget is not in the layout.
339 if (i === -1) {
340 return;
341 }
342
343 // Remove the widget from the layout.
344 let item = ArrayExt.removeAt(this._items, i)!;
345
346 // Detach the widget from the parent.
347 if (this.parent) {
348 this.detachWidget(widget);
349 }
350
351 // Dispose the layout item.
352 item.dispose();
353 }
354
355 /**
356 * Perform layout initialization which requires the parent widget.
357 */
358 protected init(): void {
359 super.init();
360 each(this, widget => {
361 this.attachWidget(widget);
362 });
363 }
364
365 /**
366 * Attach a widget to the parent's DOM node.
367 *
368 * @param widget - The widget to attach to the parent.
369 */
370 protected attachWidget(widget: Widget): void {
371 // Send a `'before-attach'` message if the parent is attached.
372 if (this.parent!.isAttached) {
373 MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
374 }
375
376 // Add the widget's node to the parent.
377 this.parent!.node.appendChild(widget.node);
378
379 // Send an `'after-attach'` message if the parent is attached.
380 if (this.parent!.isAttached) {
381 MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
382 }
383
384 // Post a fit request for the parent widget.
385 this.parent!.fit();
386 }
387
388 /**
389 * Detach a widget from the parent's DOM node.
390 *
391 * @param widget - The widget to detach from the parent.
392 */
393 protected detachWidget(widget: Widget): void {
394 // Send a `'before-detach'` message if the parent is attached.
395 if (this.parent!.isAttached) {
396 MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
397 }
398
399 // Remove the widget's node from the parent.
400 this.parent!.node.removeChild(widget.node);
401
402 // Send an `'after-detach'` message if the parent is attached.
403 if (this.parent!.isAttached) {
404 MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
405 }
406
407 // Post a fit request for the parent widget.
408 this.parent!.fit();
409 }
410
411 /**
412 * A message handler invoked on a `'before-show'` message.
413 */
414 protected onBeforeShow(msg: Message): void {
415 super.onBeforeShow(msg);
416 this.parent!.update();
417 }
418
419 /**
420 * A message handler invoked on a `'before-attach'` message.
421 */
422 protected onBeforeAttach(msg: Message): void {
423 super.onBeforeAttach(msg);
424 this.parent!.fit();
425 }
426
427 /**
428 * A message handler invoked on a `'child-shown'` message.
429 */
430 protected onChildShown(msg: Widget.ChildMessage): void {
431 this.parent!.fit();
432 }
433
434 /**
435 * A message handler invoked on a `'child-hidden'` message.
436 */
437 protected onChildHidden(msg: Widget.ChildMessage): void {
438 this.parent!.fit();
439 }
440
441 /**
442 * A message handler invoked on a `'resize'` message.
443 */
444 protected onResize(msg: Widget.ResizeMessage): void {
445 if (this.parent!.isVisible) {
446 this._update(msg.width, msg.height);
447 }
448 }
449
450 /**
451 * A message handler invoked on an `'update-request'` message.
452 */
453 protected onUpdateRequest(msg: Message): void {
454 if (this.parent!.isVisible) {
455 this._update(-1, -1);
456 }
457 }
458
459 /**
460 * A message handler invoked on a `'fit-request'` message.
461 */
462 protected onFitRequest(msg: Message): void {
463 if (this.parent!.isAttached) {
464 this._fit();
465 }
466 }
467
468 /**
469 * Fit the layout to the total size required by the widgets.
470 */
471 private _fit(): void {
472 // Reset the min sizes of the sizers.
473 for (let i = 0, n = this.rowCount; i < n; ++i) {
474 this._rowSizers[i].minSize = 0;
475 }
476 for (let i = 0, n = this.columnCount; i < n; ++i) {
477 this._columnSizers[i].minSize = 0;
478 }
479
480 // Filter for the visible layout items.
481 let items = this._items.filter(it => !it.isHidden);
482
483 // Fit the layout items.
484 for (let i = 0, n = items.length; i < n; ++i) {
485 items[i].fit();
486 }
487
488 // Get the max row and column index.
489 let maxRow = this.rowCount - 1;
490 let maxCol = this.columnCount - 1;
491
492 // Sort the items by row span.
493 items.sort(Private.rowSpanCmp);
494
495 // Update the min sizes of the row sizers.
496 for (let i = 0, n = items.length; i < n; ++i) {
497 // Fetch the item.
498 let item = items[i];
499
500 // Get the row bounds for the item.
501 let config = GridLayout.getCellConfig(item.widget);
502 let r1 = Math.min(config.row, maxRow);
503 let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
504
505 // Distribute the minimum height to the sizers as needed.
506 Private.distributeMin(this._rowSizers, r1, r2, item.minHeight);
507 }
508
509 // Sort the items by column span.
510 items.sort(Private.columnSpanCmp);
511
512 // Update the min sizes of the column sizers.
513 for (let i = 0, n = items.length; i < n; ++i) {
514 // Fetch the item.
515 let item = items[i];
516
517 // Get the column bounds for the item.
518 let config = GridLayout.getCellConfig(item.widget);
519 let c1 = Math.min(config.column, maxCol);
520 let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
521
522 // Distribute the minimum width to the sizers as needed.
523 Private.distributeMin(this._columnSizers, c1, c2, item.minWidth);
524 }
525
526 // If no size constraint is needed, just update the parent.
527 if (this.fitPolicy === 'set-no-constraint') {
528 MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
529 return;
530 }
531
532 // Set up the computed min size.
533 let minH = maxRow * this._rowSpacing;
534 let minW = maxCol * this._columnSpacing;
535
536 // Add the sizer minimums to the computed min size.
537 for (let i = 0, n = this.rowCount; i < n; ++i) {
538 minH += this._rowSizers[i].minSize;
539 }
540 for (let i = 0, n = this.columnCount; i < n; ++i) {
541 minW += this._columnSizers[i].minSize;
542 }
543
544 // Update the box sizing and add it to the computed min size.
545 let box = (this._box = ElementExt.boxSizing(this.parent!.node));
546 minW += box.horizontalSum;
547 minH += box.verticalSum;
548
549 // Update the parent's min size constraints.
550 let style = this.parent!.node.style;
551 style.minWidth = `${minW}px`;
552 style.minHeight = `${minH}px`;
553
554 // Set the dirty flag to ensure only a single update occurs.
555 this._dirty = true;
556
557 // Notify the ancestor that it should fit immediately. This may
558 // cause a resize of the parent, fulfilling the required update.
559 if (this.parent!.parent) {
560 MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
561 }
562
563 // If the dirty flag is still set, the parent was not resized.
564 // Trigger the required update on the parent widget immediately.
565 if (this._dirty) {
566 MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
567 }
568 }
569
570 /**
571 * Update the layout position and size of the widgets.
572 *
573 * The parent offset dimensions should be `-1` if unknown.
574 */
575 private _update(offsetWidth: number, offsetHeight: number): void {
576 // Clear the dirty flag to indicate the update occurred.
577 this._dirty = false;
578
579 // Measure the parent if the offset dimensions are unknown.
580 if (offsetWidth < 0) {
581 offsetWidth = this.parent!.node.offsetWidth;
582 }
583 if (offsetHeight < 0) {
584 offsetHeight = this.parent!.node.offsetHeight;
585 }
586
587 // Ensure the parent box sizing data is computed.
588 if (!this._box) {
589 this._box = ElementExt.boxSizing(this.parent!.node);
590 }
591
592 // Compute the layout area adjusted for border and padding.
593 let top = this._box.paddingTop;
594 let left = this._box.paddingLeft;
595 let width = offsetWidth - this._box.horizontalSum;
596 let height = offsetHeight - this._box.verticalSum;
597
598 // Get the max row and column index.
599 let maxRow = this.rowCount - 1;
600 let maxCol = this.columnCount - 1;
601
602 // Compute the total fixed row and column space.
603 let fixedRowSpace = maxRow * this._rowSpacing;
604 let fixedColSpace = maxCol * this._columnSpacing;
605
606 // Distribute the available space to the box sizers.
607 BoxEngine.calc(this._rowSizers, Math.max(0, height - fixedRowSpace));
608 BoxEngine.calc(this._columnSizers, Math.max(0, width - fixedColSpace));
609
610 // Update the row start positions.
611 for (let i = 0, pos = top, n = this.rowCount; i < n; ++i) {
612 this._rowStarts[i] = pos;
613 pos += this._rowSizers[i].size + this._rowSpacing;
614 }
615
616 // Update the column start positions.
617 for (let i = 0, pos = left, n = this.columnCount; i < n; ++i) {
618 this._columnStarts[i] = pos;
619 pos += this._columnSizers[i].size + this._columnSpacing;
620 }
621
622 // Update the geometry of the layout items.
623 for (let i = 0, n = this._items.length; i < n; ++i) {
624 // Fetch the item.
625 let item = this._items[i];
626
627 // Ignore hidden items.
628 if (item.isHidden) {
629 continue;
630 }
631
632 // Fetch the cell bounds for the widget.
633 let config = GridLayout.getCellConfig(item.widget);
634 let r1 = Math.min(config.row, maxRow);
635 let c1 = Math.min(config.column, maxCol);
636 let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
637 let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);
638
639 // Compute the cell geometry.
640 let x = this._columnStarts[c1];
641 let y = this._rowStarts[r1];
642 let w = this._columnStarts[c2] + this._columnSizers[c2].size - x;
643 let h = this._rowStarts[r2] + this._rowSizers[r2].size - y;
644
645 // Update the geometry of the layout item.
646 item.update(x, y, w, h);
647 }
648 }
649
650 private _dirty = false;
651 private _rowSpacing = 4;
652 private _columnSpacing = 4;
653 private _items: LayoutItem[] = [];
654 private _rowStarts: number[] = [];
655 private _columnStarts: number[] = [];
656 private _rowSizers: BoxSizer[] = [new BoxSizer()];
657 private _columnSizers: BoxSizer[] = [new BoxSizer()];
658 private _box: ElementExt.IBoxSizing | null = null;
659}
660
661/**
662 * The namespace for the `GridLayout` class statics.
663 */
664export namespace GridLayout {
665 /**
666 * An options object for initializing a grid layout.
667 */
668 export interface IOptions extends Layout.IOptions {
669 /**
670 * The initial row count for the layout.
671 *
672 * The default is `1`.
673 */
674 rowCount?: number;
675
676 /**
677 * The initial column count for the layout.
678 *
679 * The default is `1`.
680 */
681 columnCount?: number;
682
683 /**
684 * The spacing between rows in the layout.
685 *
686 * The default is `4`.
687 */
688 rowSpacing?: number;
689
690 /**
691 * The spacing between columns in the layout.
692 *
693 * The default is `4`.
694 */
695 columnSpacing?: number;
696 }
697
698 /**
699 * An object which holds the cell configuration for a widget.
700 */
701 export interface ICellConfig {
702 /**
703 * The row index for the widget.
704 */
705 readonly row: number;
706
707 /**
708 * The column index for the widget.
709 */
710 readonly column: number;
711
712 /**
713 * The row span for the widget.
714 */
715 readonly rowSpan: number;
716
717 /**
718 * The column span for the widget.
719 */
720 readonly columnSpan: number;
721 }
722
723 /**
724 * Get the cell config for the given widget.
725 *
726 * @param widget - The widget of interest.
727 *
728 * @returns The cell config for the widget.
729 */
730 export function getCellConfig(widget: Widget): ICellConfig {
731 return Private.cellConfigProperty.get(widget);
732 }
733
734 /**
735 * Set the cell config for the given widget.
736 *
737 * @param widget - The widget of interest.
738 *
739 * @param value - The value for the cell config.
740 */
741 export function setCellConfig(
742 widget: Widget,
743 value: Partial<ICellConfig>
744 ): void {
745 Private.cellConfigProperty.set(widget, Private.normalizeConfig(value));
746 }
747}
748
749/**
750 * The namespace for the module implementation details.
751 */
752namespace Private {
753 /**
754 * The property descriptor for the widget cell config.
755 */
756 export const cellConfigProperty = new AttachedProperty<
757 Widget,
758 GridLayout.ICellConfig
759 >({
760 name: 'cellConfig',
761 create: () => ({ row: 0, column: 0, rowSpan: 1, columnSpan: 1 }),
762 changed: onChildCellConfigChanged
763 });
764
765 /**
766 * Normalize a partial cell config object.
767 */
768 export function normalizeConfig(
769 config: Partial<GridLayout.ICellConfig>
770 ): GridLayout.ICellConfig {
771 let row = Math.max(0, Math.floor(config.row || 0));
772 let column = Math.max(0, Math.floor(config.column || 0));
773 let rowSpan = Math.max(1, Math.floor(config.rowSpan || 0));
774 let columnSpan = Math.max(1, Math.floor(config.columnSpan || 0));
775 return { row, column, rowSpan, columnSpan };
776 }
777
778 /**
779 * Clamp a value to an integer >= 0.
780 */
781 export function clampValue(value: number): number {
782 return Math.max(0, Math.floor(value));
783 }
784
785 /**
786 * A sort comparison function for row spans.
787 */
788 export function rowSpanCmp(a: LayoutItem, b: LayoutItem): number {
789 let c1 = cellConfigProperty.get(a.widget);
790 let c2 = cellConfigProperty.get(b.widget);
791 return c1.rowSpan - c2.rowSpan;
792 }
793
794 /**
795 * A sort comparison function for column spans.
796 */
797 export function columnSpanCmp(a: LayoutItem, b: LayoutItem): number {
798 let c1 = cellConfigProperty.get(a.widget);
799 let c2 = cellConfigProperty.get(b.widget);
800 return c1.columnSpan - c2.columnSpan;
801 }
802
803 /**
804 * Reallocate the box sizers for the given grid dimensions.
805 */
806 export function reallocSizers(sizers: BoxSizer[], count: number): void {
807 // Coerce the count to the valid range.
808 count = Math.max(1, Math.floor(count));
809
810 // Add the missing sizers.
811 while (sizers.length < count) {
812 sizers.push(new BoxSizer());
813 }
814
815 // Remove the extra sizers.
816 if (sizers.length > count) {
817 sizers.length = count;
818 }
819 }
820
821 /**
822 * Distribute a min size constraint across a range of sizers.
823 */
824 export function distributeMin(
825 sizers: BoxSizer[],
826 i1: number,
827 i2: number,
828 minSize: number
829 ): void {
830 // Sanity check the indices.
831 if (i2 < i1) {
832 return;
833 }
834
835 // Handle the simple case of no cell span.
836 if (i1 === i2) {
837 let sizer = sizers[i1];
838 sizer.minSize = Math.max(sizer.minSize, minSize);
839 return;
840 }
841
842 // Compute the total current min size of the span.
843 let totalMin = 0;
844 for (let i = i1; i <= i2; ++i) {
845 totalMin += sizers[i].minSize;
846 }
847
848 // Do nothing if the total is greater than the required.
849 if (totalMin >= minSize) {
850 return;
851 }
852
853 // Compute the portion of the space to allocate to each sizer.
854 let portion = (minSize - totalMin) / (i2 - i1 + 1);
855
856 // Add the portion to each sizer.
857 for (let i = i1; i <= i2; ++i) {
858 sizers[i].minSize += portion;
859 }
860 }
861
862 /**
863 * The change handler for the child cell config property.
864 */
865 function onChildCellConfigChanged(child: Widget): void {
866 if (child.parent && child.parent.layout instanceof GridLayout) {
867 child.parent.fit();
868 }
869 }
870}