UNPKG

23.3 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 } 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 { LayoutItem } from './layout';
21
22import { PanelLayout } from './panellayout';
23
24import { Utils } from './utils';
25
26import { Widget } from './widget';
27
28/**
29 * A layout which arranges its widgets into resizable sections.
30 */
31export class SplitLayout extends PanelLayout {
32 /**
33 * Construct a new split layout.
34 *
35 * @param options - The options for initializing the layout.
36 */
37 constructor(options: SplitLayout.IOptions) {
38 super();
39 this.renderer = options.renderer;
40 if (options.orientation !== undefined) {
41 this._orientation = options.orientation;
42 }
43 if (options.alignment !== undefined) {
44 this._alignment = options.alignment;
45 }
46 if (options.spacing !== undefined) {
47 this._spacing = Utils.clampDimension(options.spacing);
48 }
49 }
50
51 /**
52 * Dispose of the resources held by the layout.
53 */
54 dispose(): void {
55 // Dispose of the layout items.
56 each(this._items, item => {
57 item.dispose();
58 });
59
60 // Clear the layout state.
61 this._box = null;
62 this._items.length = 0;
63 this._sizers.length = 0;
64 this._handles.length = 0;
65
66 // Dispose of the rest of the layout.
67 super.dispose();
68 }
69
70 /**
71 * The renderer used by the split layout.
72 */
73 readonly renderer: SplitLayout.IRenderer;
74
75 /**
76 * Get the layout orientation for the split layout.
77 */
78 get orientation(): SplitLayout.Orientation {
79 return this._orientation;
80 }
81
82 /**
83 * Set the layout orientation for the split layout.
84 */
85 set orientation(value: SplitLayout.Orientation) {
86 if (this._orientation === value) {
87 return;
88 }
89 this._orientation = value;
90 if (!this.parent) {
91 return;
92 }
93 this.parent.dataset['orientation'] = value;
94 this.parent.fit();
95 }
96
97 /**
98 * Get the content alignment for the split layout.
99 *
100 * #### Notes
101 * This is the alignment of the widgets in the layout direction.
102 *
103 * The alignment has no effect if the widgets can expand to fill the
104 * entire split layout.
105 */
106 get alignment(): SplitLayout.Alignment {
107 return this._alignment;
108 }
109
110 /**
111 * Set the content alignment for the split layout.
112 *
113 * #### Notes
114 * This is the alignment of the widgets in the layout direction.
115 *
116 * The alignment has no effect if the widgets can expand to fill the
117 * entire split layout.
118 */
119 set alignment(value: SplitLayout.Alignment) {
120 if (this._alignment === value) {
121 return;
122 }
123 this._alignment = value;
124 if (!this.parent) {
125 return;
126 }
127 this.parent.dataset['alignment'] = value;
128 this.parent.update();
129 }
130
131 /**
132 * Get the inter-element spacing for the split layout.
133 */
134 get spacing(): number {
135 return this._spacing;
136 }
137
138 /**
139 * Set the inter-element spacing for the split layout.
140 */
141 set spacing(value: number) {
142 value = Utils.clampDimension(value);
143 if (this._spacing === value) {
144 return;
145 }
146 this._spacing = value;
147 if (!this.parent) {
148 return;
149 }
150 this.parent.fit();
151 }
152
153 /**
154 * A read-only array of the split handles in the layout.
155 */
156 get handles(): ReadonlyArray<HTMLDivElement> {
157 return this._handles;
158 }
159
160 /**
161 * Get the relative sizes of the widgets in the layout.
162 *
163 * @returns A new array of the relative sizes of the widgets.
164 *
165 * #### Notes
166 * The returned sizes reflect the sizes of the widgets normalized
167 * relative to their siblings.
168 *
169 * This method **does not** measure the DOM nodes.
170 */
171 relativeSizes(): number[] {
172 return Private.normalize(this._sizers.map(sizer => sizer.size));
173 }
174
175 /**
176 * Set the relative sizes for the widgets in the layout.
177 *
178 * @param sizes - The relative sizes for the widgets in the panel.
179 *
180 * #### Notes
181 * Extra values are ignored, too few will yield an undefined layout.
182 *
183 * The actual geometry of the DOM nodes is updated asynchronously.
184 */
185 setRelativeSizes(sizes: number[]): void {
186 // Copy the sizes and pad with zeros as needed.
187 let n = this._sizers.length;
188 let temp = sizes.slice(0, n);
189 while (temp.length < n) {
190 temp.push(0);
191 }
192
193 // Normalize the padded sizes.
194 let normed = Private.normalize(temp);
195
196 // Apply the normalized sizes to the sizers.
197 for (let i = 0; i < n; ++i) {
198 let sizer = this._sizers[i];
199 sizer.sizeHint = normed[i];
200 sizer.size = normed[i];
201 }
202
203 // Set the flag indicating the sizes are normalized.
204 this._hasNormedSizes = true;
205
206 // Trigger an update of the parent widget.
207 if (this.parent) {
208 this.parent.update();
209 }
210 }
211
212 /**
213 * Move the offset position of a split handle.
214 *
215 * @param index - The index of the handle of the interest.
216 *
217 * @param position - The desired offset position of the handle.
218 *
219 * #### Notes
220 * The position is relative to the offset parent.
221 *
222 * This will move the handle as close as possible to the desired
223 * position. The sibling widgets will be adjusted as necessary.
224 */
225 moveHandle(index: number, position: number): void {
226 // Bail if the index is invalid or the handle is hidden.
227 let handle = this._handles[index];
228 if (!handle || handle.classList.contains('lm-mod-hidden')) {
229 return;
230 }
231
232 // Compute the desired delta movement for the handle.
233 let delta: number;
234 if (this._orientation === 'horizontal') {
235 delta = position - handle.offsetLeft;
236 } else {
237 delta = position - handle.offsetTop;
238 }
239
240 // Bail if there is no handle movement.
241 if (delta === 0) {
242 return;
243 }
244
245 // Prevent widget resizing unless needed.
246 for (let sizer of this._sizers) {
247 if (sizer.size > 0) {
248 sizer.sizeHint = sizer.size;
249 }
250 }
251
252 // Adjust the sizers to reflect the handle movement.
253 BoxEngine.adjust(this._sizers, index, delta);
254
255 // Update the layout of the widgets.
256 if (this.parent) {
257 this.parent.update();
258 }
259 }
260
261 /**
262 * Perform layout initialization which requires the parent widget.
263 */
264 protected init(): void {
265 this.parent!.dataset['orientation'] = this.orientation;
266 this.parent!.dataset['alignment'] = this.alignment;
267 super.init();
268 }
269
270 /**
271 * Attach a widget to the parent's DOM node.
272 *
273 * @param index - The current index of the widget in the layout.
274 *
275 * @param widget - The widget to attach to the parent.
276 *
277 * #### Notes
278 * This is a reimplementation of the superclass method.
279 */
280 protected attachWidget(index: number, widget: Widget): void {
281 // Create the item, handle, and sizer for the new widget.
282 let item = new LayoutItem(widget);
283 let handle = Private.createHandle(this.renderer);
284 let average = Private.averageSize(this._sizers);
285 let sizer = Private.createSizer(average);
286
287 // Insert the item, handle, and sizer into the internal arrays.
288 ArrayExt.insert(this._items, index, item);
289 ArrayExt.insert(this._sizers, index, sizer);
290 ArrayExt.insert(this._handles, index, handle);
291
292 // Send a `'before-attach'` message if the parent is attached.
293 if (this.parent!.isAttached) {
294 MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
295 }
296
297 // Add the widget and handle nodes to the parent.
298 this.parent!.node.appendChild(widget.node);
299 this.parent!.node.appendChild(handle);
300
301 // Send an `'after-attach'` message if the parent is attached.
302 if (this.parent!.isAttached) {
303 MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
304 }
305
306 // Post a fit request for the parent widget.
307 this.parent!.fit();
308 }
309
310 /**
311 * Move a widget in the parent's DOM node.
312 *
313 * @param fromIndex - The previous index of the widget in the layout.
314 *
315 * @param toIndex - The current index of the widget in the layout.
316 *
317 * @param widget - The widget to move in the parent.
318 *
319 * #### Notes
320 * This is a reimplementation of the superclass method.
321 */
322 protected moveWidget(
323 fromIndex: number,
324 toIndex: number,
325 widget: Widget
326 ): void {
327 // Move the item, sizer, and handle for the widget.
328 ArrayExt.move(this._items, fromIndex, toIndex);
329 ArrayExt.move(this._sizers, fromIndex, toIndex);
330 ArrayExt.move(this._handles, fromIndex, toIndex);
331
332 // Post a fit request to the parent to show/hide last handle.
333 this.parent!.fit();
334 }
335
336 /**
337 * Detach a widget from the parent's DOM node.
338 *
339 * @param index - The previous index of the widget in the layout.
340 *
341 * @param widget - The widget to detach from the parent.
342 *
343 * #### Notes
344 * This is a reimplementation of the superclass method.
345 */
346 protected detachWidget(index: number, widget: Widget): void {
347 // Remove the item, handle, and sizer for the widget.
348 let item = ArrayExt.removeAt(this._items, index);
349 let handle = ArrayExt.removeAt(this._handles, index);
350 ArrayExt.removeAt(this._sizers, index);
351
352 // Send a `'before-detach'` message if the parent is attached.
353 if (this.parent!.isAttached) {
354 MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
355 }
356
357 // Remove the widget and handle nodes from the parent.
358 this.parent!.node.removeChild(widget.node);
359 this.parent!.node.removeChild(handle!);
360
361 // Send an `'after-detach'` message if the parent is attached.
362 if (this.parent!.isAttached) {
363 MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
364 }
365
366 // Dispose of the layout item.
367 item!.dispose();
368
369 // Post a fit request for the parent widget.
370 this.parent!.fit();
371 }
372
373 /**
374 * A message handler invoked on a `'before-show'` message.
375 */
376 protected onBeforeShow(msg: Message): void {
377 super.onBeforeShow(msg);
378 this.parent!.update();
379 }
380
381 /**
382 * A message handler invoked on a `'before-attach'` message.
383 */
384 protected onBeforeAttach(msg: Message): void {
385 super.onBeforeAttach(msg);
386 this.parent!.fit();
387 }
388
389 /**
390 * A message handler invoked on a `'child-shown'` message.
391 */
392 protected onChildShown(msg: Widget.ChildMessage): void {
393 this.parent!.fit();
394 }
395
396 /**
397 * A message handler invoked on a `'child-hidden'` message.
398 */
399 protected onChildHidden(msg: Widget.ChildMessage): void {
400 this.parent!.fit();
401 }
402
403 /**
404 * A message handler invoked on a `'resize'` message.
405 */
406 protected onResize(msg: Widget.ResizeMessage): void {
407 if (this.parent!.isVisible) {
408 this._update(msg.width, msg.height);
409 }
410 }
411
412 /**
413 * A message handler invoked on an `'update-request'` message.
414 */
415 protected onUpdateRequest(msg: Message): void {
416 if (this.parent!.isVisible) {
417 this._update(-1, -1);
418 }
419 }
420
421 /**
422 * A message handler invoked on a `'fit-request'` message.
423 */
424 protected onFitRequest(msg: Message): void {
425 if (this.parent!.isAttached) {
426 this._fit();
427 }
428 }
429
430 /**
431 * Update the item position.
432 *
433 * @param i Item index
434 * @param isHorizontal Whether the layout is horizontal or not
435 * @param left Left position in pixels
436 * @param top Top position in pixels
437 * @param height Item height
438 * @param width Item width
439 * @param size Item size
440 */
441 protected updateItemPosition(
442 i: number,
443 isHorizontal: boolean,
444 left: number,
445 top: number,
446 height: number,
447 width: number,
448 size: number
449 ): void {
450 const item = this._items[i];
451 if (item.isHidden) {
452 return;
453 }
454
455 // Fetch the style for the handle.
456 let handleStyle = this._handles[i].style;
457
458 // Update the widget and handle, and advance the relevant edge.
459 if (isHorizontal) {
460 left += this.widgetOffset;
461 item.update(left, top, size, height);
462 left += size;
463 handleStyle.top = `${top}px`;
464 handleStyle.left = `${left}px`;
465 handleStyle.width = `${this._spacing}px`;
466 handleStyle.height = `${height}px`;
467 } else {
468 top += this.widgetOffset;
469 item.update(left, top, width, size);
470 top += size;
471 handleStyle.top = `${top}px`;
472 handleStyle.left = `${left}px`;
473 handleStyle.width = `${width}px`;
474 handleStyle.height = `${this._spacing}px`;
475 }
476 }
477
478 /**
479 * Fit the layout to the total size required by the widgets.
480 */
481 private _fit(): void {
482 // Update the handles and track the visible widget count.
483 let nVisible = 0;
484 let lastHandleIndex = -1;
485 for (let i = 0, n = this._items.length; i < n; ++i) {
486 if (this._items[i].isHidden) {
487 this._handles[i].classList.add('lm-mod-hidden');
488 /* <DEPRECATED> */
489 this._handles[i].classList.add('p-mod-hidden');
490 /* </DEPRECATED> */
491 } else {
492 this._handles[i].classList.remove('lm-mod-hidden');
493 /* <DEPRECATED> */
494 this._handles[i].classList.remove('p-mod-hidden');
495 /* </DEPRECATED> */
496 lastHandleIndex = i;
497 nVisible++;
498 }
499 }
500
501 // Hide the handle for the last visible widget.
502 if (lastHandleIndex !== -1) {
503 this._handles[lastHandleIndex].classList.add('lm-mod-hidden');
504 /* <DEPRECATED> */
505 this._handles[lastHandleIndex].classList.add('p-mod-hidden');
506 /* </DEPRECATED> */
507 }
508
509 // Update the fixed space for the visible items.
510 this._fixed =
511 this._spacing * Math.max(0, nVisible - 1) +
512 this.widgetOffset * this._items.length;
513
514 // Setup the computed minimum size.
515 let horz = this._orientation === 'horizontal';
516 let minW = horz ? this._fixed : 0;
517 let minH = horz ? 0 : this._fixed;
518
519 // Update the sizers and computed size limits.
520 for (let i = 0, n = this._items.length; i < n; ++i) {
521 // Fetch the item and corresponding box sizer.
522 let item = this._items[i];
523 let sizer = this._sizers[i];
524
525 // Prevent resizing unless necessary.
526 if (sizer.size > 0) {
527 sizer.sizeHint = sizer.size;
528 }
529
530 // If the item is hidden, it should consume zero size.
531 if (item.isHidden) {
532 sizer.minSize = 0;
533 sizer.maxSize = 0;
534 continue;
535 }
536
537 // Update the size limits for the item.
538 item.fit();
539
540 // Update the stretch factor.
541 sizer.stretch = SplitLayout.getStretch(item.widget);
542
543 // Update the sizer limits and computed min size.
544 if (horz) {
545 sizer.minSize = item.minWidth;
546 sizer.maxSize = item.maxWidth;
547 minW += item.minWidth;
548 minH = Math.max(minH, item.minHeight);
549 } else {
550 sizer.minSize = item.minHeight;
551 sizer.maxSize = item.maxHeight;
552 minH += item.minHeight;
553 minW = Math.max(minW, item.minWidth);
554 }
555 }
556
557 // Update the box sizing and add it to the computed min size.
558 let box = (this._box = ElementExt.boxSizing(this.parent!.node));
559 minW += box.horizontalSum;
560 minH += box.verticalSum;
561
562 // Update the parent's min size constraints.
563 let style = this.parent!.node.style;
564 style.minWidth = `${minW}px`;
565 style.minHeight = `${minH}px`;
566
567 // Set the dirty flag to ensure only a single update occurs.
568 this._dirty = true;
569
570 // Notify the ancestor that it should fit immediately. This may
571 // cause a resize of the parent, fulfilling the required update.
572 if (this.parent!.parent) {
573 MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
574 }
575
576 // If the dirty flag is still set, the parent was not resized.
577 // Trigger the required update on the parent widget immediately.
578 if (this._dirty) {
579 MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
580 }
581 }
582
583 /**
584 * Update the layout position and size of the widgets.
585 *
586 * The parent offset dimensions should be `-1` if unknown.
587 */
588 private _update(offsetWidth: number, offsetHeight: number): void {
589 // Clear the dirty flag to indicate the update occurred.
590 this._dirty = false;
591
592 // Compute the visible item count.
593 let nVisible = 0;
594 for (let i = 0, n = this._items.length; i < n; ++i) {
595 nVisible += +!this._items[i].isHidden;
596 }
597
598 // Bail early if there are no visible items to layout.
599 if (nVisible === 0 && this.widgetOffset === 0) {
600 return;
601 }
602
603 // Measure the parent if the offset dimensions are unknown.
604 if (offsetWidth < 0) {
605 offsetWidth = this.parent!.node.offsetWidth;
606 }
607 if (offsetHeight < 0) {
608 offsetHeight = this.parent!.node.offsetHeight;
609 }
610
611 // Ensure the parent box sizing data is computed.
612 if (!this._box) {
613 this._box = ElementExt.boxSizing(this.parent!.node);
614 }
615
616 // Compute the actual layout bounds adjusted for border and padding.
617 let top = this._box.paddingTop;
618 let left = this._box.paddingLeft;
619 let width = offsetWidth - this._box.horizontalSum;
620 let height = offsetHeight - this._box.verticalSum;
621
622 // Set up the variables for justification and alignment offset.
623 let extra = 0;
624 let offset = 0;
625 let horz = this._orientation === 'horizontal';
626
627 if (nVisible > 0) {
628 // Compute the adjusted layout space.
629 let space: number;
630 if (horz) {
631 // left += this.widgetOffset;
632 space = Math.max(0, width - this._fixed);
633 } else {
634 // top += this.widgetOffset;
635 space = Math.max(0, height - this._fixed);
636 }
637
638 // Scale the size hints if they are normalized.
639 if (this._hasNormedSizes) {
640 for (let sizer of this._sizers) {
641 sizer.sizeHint *= space;
642 }
643 this._hasNormedSizes = false;
644 }
645
646 // Distribute the layout space to the box sizers.
647 let delta = BoxEngine.calc(this._sizers, space);
648
649 // Account for alignment if there is extra layout space.
650 if (delta > 0) {
651 switch (this._alignment) {
652 case 'start':
653 break;
654 case 'center':
655 extra = 0;
656 offset = delta / 2;
657 break;
658 case 'end':
659 extra = 0;
660 offset = delta;
661 break;
662 case 'justify':
663 extra = delta / nVisible;
664 offset = 0;
665 break;
666 default:
667 throw 'unreachable';
668 }
669 }
670 }
671
672 // Layout the items using the computed box sizes.
673 for (let i = 0, n = this._items.length; i < n; ++i) {
674 // Fetch the item.
675 const item = this._items[i];
676
677 // Fetch the computed size for the widget.
678 const size = item.isHidden ? 0 : this._sizers[i].size + extra;
679
680 this.updateItemPosition(
681 i,
682 horz,
683 horz ? left + offset : left,
684 horz ? top : top + offset,
685 height,
686 width,
687 size
688 );
689
690 const fullOffset =
691 this.widgetOffset +
692 (this._handles[i].classList.contains('lm-mod-hidden')
693 ? 0
694 : this._spacing);
695
696 if (horz) {
697 left += size + fullOffset;
698 } else {
699 top += size + fullOffset;
700 }
701 }
702 }
703
704 protected widgetOffset = 0;
705 private _fixed = 0;
706 private _spacing = 4;
707 private _dirty = false;
708 private _hasNormedSizes = false;
709 private _sizers: BoxSizer[] = [];
710 private _items: LayoutItem[] = [];
711 private _handles: HTMLDivElement[] = [];
712 private _box: ElementExt.IBoxSizing | null = null;
713 private _alignment: SplitLayout.Alignment = 'start';
714 private _orientation: SplitLayout.Orientation = 'horizontal';
715}
716
717/**
718 * The namespace for the `SplitLayout` class statics.
719 */
720export namespace SplitLayout {
721 /**
722 * A type alias for a split layout orientation.
723 */
724 export type Orientation = 'horizontal' | 'vertical';
725
726 /**
727 * A type alias for a split layout alignment.
728 */
729 export type Alignment = 'start' | 'center' | 'end' | 'justify';
730
731 /**
732 * An options object for initializing a split layout.
733 */
734 export interface IOptions {
735 /**
736 * The renderer to use for the split layout.
737 */
738 renderer: IRenderer;
739
740 /**
741 * The orientation of the layout.
742 *
743 * The default is `'horizontal'`.
744 */
745 orientation?: Orientation;
746
747 /**
748 * The content alignment of the layout.
749 *
750 * The default is `'start'`.
751 */
752 alignment?: Alignment;
753
754 /**
755 * The spacing between items in the layout.
756 *
757 * The default is `4`.
758 */
759 spacing?: number;
760 }
761
762 /**
763 * A renderer for use with a split layout.
764 */
765 export interface IRenderer {
766 /**
767 * Create a new handle for use with a split layout.
768 *
769 * @returns A new handle element.
770 */
771 createHandle(): HTMLDivElement;
772 }
773
774 /**
775 * Get the split layout stretch factor for the given widget.
776 *
777 * @param widget - The widget of interest.
778 *
779 * @returns The split layout stretch factor for the widget.
780 */
781 export function getStretch(widget: Widget): number {
782 return Private.stretchProperty.get(widget);
783 }
784
785 /**
786 * Set the split layout stretch factor for the given widget.
787 *
788 * @param widget - The widget of interest.
789 *
790 * @param value - The value for the stretch factor.
791 */
792 export function setStretch(widget: Widget, value: number): void {
793 Private.stretchProperty.set(widget, value);
794 }
795}
796
797/**
798 * The namespace for the module implementation details.
799 */
800namespace Private {
801 /**
802 * The property descriptor for a widget stretch factor.
803 */
804 export const stretchProperty = new AttachedProperty<Widget, number>({
805 name: 'stretch',
806 create: () => 0,
807 coerce: (owner, value) => Math.max(0, Math.floor(value)),
808 changed: onChildSizingChanged
809 });
810
811 /**
812 * Create a new box sizer with the given size hint.
813 */
814 export function createSizer(size: number): BoxSizer {
815 let sizer = new BoxSizer();
816 sizer.sizeHint = Math.floor(size);
817 return sizer;
818 }
819
820 /**
821 * Create a new split handle node using the given renderer.
822 */
823 export function createHandle(
824 renderer: SplitLayout.IRenderer
825 ): HTMLDivElement {
826 let handle = renderer.createHandle();
827 handle.style.position = 'absolute';
828 return handle;
829 }
830
831 /**
832 * Compute the average size of an array of box sizers.
833 */
834 export function averageSize(sizers: BoxSizer[]): number {
835 return sizers.reduce((v, s) => v + s.size, 0) / sizers.length || 0;
836 }
837
838 /**
839 * Normalize an array of values.
840 */
841 export function normalize(values: number[]): number[] {
842 let n = values.length;
843 if (n === 0) {
844 return [];
845 }
846 let sum = values.reduce((a, b) => a + Math.abs(b), 0);
847 return sum === 0 ? values.map(v => 1 / n) : values.map(v => v / sum);
848 }
849
850 /**
851 * The change handler for the attached sizing properties.
852 */
853 function onChildSizingChanged(child: Widget): void {
854 if (child.parent && child.parent.layout instanceof SplitLayout) {
855 child.parent.fit();
856 }
857 }
858}