UNPKG

17.8 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 in a single row or column.
30 */
31export class BoxLayout extends PanelLayout {
32 /**
33 * Construct a new box layout.
34 *
35 * @param options - The options for initializing the layout.
36 */
37 constructor(options: BoxLayout.IOptions = {}) {
38 super();
39 if (options.direction !== undefined) {
40 this._direction = options.direction;
41 }
42 if (options.alignment !== undefined) {
43 this._alignment = options.alignment;
44 }
45 if (options.spacing !== undefined) {
46 this._spacing = Utils.clampDimension(options.spacing);
47 }
48 }
49
50 /**
51 * Dispose of the resources held by the layout.
52 */
53 dispose(): void {
54 // Dispose of the layout items.
55 each(this._items, item => {
56 item.dispose();
57 });
58
59 // Clear the layout state.
60 this._box = null;
61 this._items.length = 0;
62 this._sizers.length = 0;
63
64 // Dispose of the rest of the layout.
65 super.dispose();
66 }
67
68 /**
69 * Get the layout direction for the box layout.
70 */
71 get direction(): BoxLayout.Direction {
72 return this._direction;
73 }
74
75 /**
76 * Set the layout direction for the box layout.
77 */
78 set direction(value: BoxLayout.Direction) {
79 if (this._direction === value) {
80 return;
81 }
82 this._direction = value;
83 if (!this.parent) {
84 return;
85 }
86 this.parent.dataset['direction'] = value;
87 this.parent.fit();
88 }
89
90 /**
91 * Get the content alignment for the box layout.
92 *
93 * #### Notes
94 * This is the alignment of the widgets in the layout direction.
95 *
96 * The alignment has no effect if the widgets can expand to fill the
97 * entire box layout.
98 */
99 get alignment(): BoxLayout.Alignment {
100 return this._alignment;
101 }
102
103 /**
104 * Set the content alignment for the box layout.
105 *
106 * #### Notes
107 * This is the alignment of the widgets in the layout direction.
108 *
109 * The alignment has no effect if the widgets can expand to fill the
110 * entire box layout.
111 */
112 set alignment(value: BoxLayout.Alignment) {
113 if (this._alignment === value) {
114 return;
115 }
116 this._alignment = value;
117 if (!this.parent) {
118 return;
119 }
120 this.parent.dataset['alignment'] = value;
121 this.parent.update();
122 }
123
124 /**
125 * Get the inter-element spacing for the box layout.
126 */
127 get spacing(): number {
128 return this._spacing;
129 }
130
131 /**
132 * Set the inter-element spacing for the box layout.
133 */
134 set spacing(value: number) {
135 value = Utils.clampDimension(value);
136 if (this._spacing === value) {
137 return;
138 }
139 this._spacing = value;
140 if (!this.parent) {
141 return;
142 }
143 this.parent.fit();
144 }
145
146 /**
147 * Perform layout initialization which requires the parent widget.
148 */
149 protected init(): void {
150 this.parent!.dataset['direction'] = this.direction;
151 this.parent!.dataset['alignment'] = this.alignment;
152 super.init();
153 }
154
155 /**
156 * Attach a widget to the parent's DOM node.
157 *
158 * @param index - The current index of the widget in the layout.
159 *
160 * @param widget - The widget to attach to the parent.
161 *
162 * #### Notes
163 * This is a reimplementation of the superclass method.
164 */
165 protected attachWidget(index: number, widget: Widget): void {
166 // Create and add a new layout item for the widget.
167 ArrayExt.insert(this._items, index, new LayoutItem(widget));
168
169 // Create and add a new sizer for the widget.
170 ArrayExt.insert(this._sizers, index, new BoxSizer());
171
172 // Send a `'before-attach'` message if the parent is attached.
173 if (this.parent!.isAttached) {
174 MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
175 }
176
177 // Add the widget's node to the parent.
178 this.parent!.node.appendChild(widget.node);
179
180 // Send an `'after-attach'` message if the parent is attached.
181 if (this.parent!.isAttached) {
182 MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
183 }
184
185 // Post a fit request for the parent widget.
186 this.parent!.fit();
187 }
188
189 /**
190 * Move a widget in the parent's DOM node.
191 *
192 * @param fromIndex - The previous index of the widget in the layout.
193 *
194 * @param toIndex - The current index of the widget in the layout.
195 *
196 * @param widget - The widget to move in the parent.
197 *
198 * #### Notes
199 * This is a reimplementation of the superclass method.
200 */
201 protected moveWidget(
202 fromIndex: number,
203 toIndex: number,
204 widget: Widget
205 ): void {
206 // Move the layout item for the widget.
207 ArrayExt.move(this._items, fromIndex, toIndex);
208
209 // Move the sizer for the widget.
210 ArrayExt.move(this._sizers, fromIndex, toIndex);
211
212 // Post an update request for the parent widget.
213 this.parent!.update();
214 }
215
216 /**
217 * Detach a widget from the parent's DOM node.
218 *
219 * @param index - The previous index of the widget in the layout.
220 *
221 * @param widget - The widget to detach from the parent.
222 *
223 * #### Notes
224 * This is a reimplementation of the superclass method.
225 */
226 protected detachWidget(index: number, widget: Widget): void {
227 // Remove the layout item for the widget.
228 let item = ArrayExt.removeAt(this._items, index);
229
230 // Remove the sizer for the widget.
231 ArrayExt.removeAt(this._sizers, index);
232
233 // Send a `'before-detach'` message if the parent is attached.
234 if (this.parent!.isAttached) {
235 MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
236 }
237
238 // Remove the widget's node from the parent.
239 this.parent!.node.removeChild(widget.node);
240
241 // Send an `'after-detach'` message if the parent is attached.
242 if (this.parent!.isAttached) {
243 MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
244 }
245
246 // Dispose of the layout item.
247 item!.dispose();
248
249 // Post a fit request for the parent widget.
250 this.parent!.fit();
251 }
252
253 /**
254 * A message handler invoked on a `'before-show'` message.
255 */
256 protected onBeforeShow(msg: Message): void {
257 super.onBeforeShow(msg);
258 this.parent!.update();
259 }
260
261 /**
262 * A message handler invoked on a `'before-attach'` message.
263 */
264 protected onBeforeAttach(msg: Message): void {
265 super.onBeforeAttach(msg);
266 this.parent!.fit();
267 }
268
269 /**
270 * A message handler invoked on a `'child-shown'` message.
271 */
272 protected onChildShown(msg: Widget.ChildMessage): void {
273 this.parent!.fit();
274 }
275
276 /**
277 * A message handler invoked on a `'child-hidden'` message.
278 */
279 protected onChildHidden(msg: Widget.ChildMessage): void {
280 this.parent!.fit();
281 }
282
283 /**
284 * A message handler invoked on a `'resize'` message.
285 */
286 protected onResize(msg: Widget.ResizeMessage): void {
287 if (this.parent!.isVisible) {
288 this._update(msg.width, msg.height);
289 }
290 }
291
292 /**
293 * A message handler invoked on an `'update-request'` message.
294 */
295 protected onUpdateRequest(msg: Message): void {
296 if (this.parent!.isVisible) {
297 this._update(-1, -1);
298 }
299 }
300
301 /**
302 * A message handler invoked on a `'fit-request'` message.
303 */
304 protected onFitRequest(msg: Message): void {
305 if (this.parent!.isAttached) {
306 this._fit();
307 }
308 }
309
310 /**
311 * Fit the layout to the total size required by the widgets.
312 */
313 private _fit(): void {
314 // Compute the visible item count.
315 let nVisible = 0;
316 for (let i = 0, n = this._items.length; i < n; ++i) {
317 nVisible += +!this._items[i].isHidden;
318 }
319
320 // Update the fixed space for the visible items.
321 this._fixed = this._spacing * Math.max(0, nVisible - 1);
322
323 // Setup the computed minimum size.
324 let horz = Private.isHorizontal(this._direction);
325 let minW = horz ? this._fixed : 0;
326 let minH = horz ? 0 : this._fixed;
327
328 // Update the sizers and computed minimum size.
329 for (let i = 0, n = this._items.length; i < n; ++i) {
330 // Fetch the item and corresponding box sizer.
331 let item = this._items[i];
332 let sizer = this._sizers[i];
333
334 // If the item is hidden, it should consume zero size.
335 if (item.isHidden) {
336 sizer.minSize = 0;
337 sizer.maxSize = 0;
338 continue;
339 }
340
341 // Update the size limits for the item.
342 item.fit();
343
344 // Update the size basis and stretch factor.
345 sizer.sizeHint = BoxLayout.getSizeBasis(item.widget);
346 sizer.stretch = BoxLayout.getStretch(item.widget);
347
348 // Update the sizer limits and computed min size.
349 if (horz) {
350 sizer.minSize = item.minWidth;
351 sizer.maxSize = item.maxWidth;
352 minW += item.minWidth;
353 minH = Math.max(minH, item.minHeight);
354 } else {
355 sizer.minSize = item.minHeight;
356 sizer.maxSize = item.maxHeight;
357 minH += item.minHeight;
358 minW = Math.max(minW, item.minWidth);
359 }
360 }
361
362 // Update the box sizing and add it to the computed min size.
363 let box = (this._box = ElementExt.boxSizing(this.parent!.node));
364 minW += box.horizontalSum;
365 minH += box.verticalSum;
366
367 // Update the parent's min size constraints.
368 let style = this.parent!.node.style;
369 style.minWidth = `${minW}px`;
370 style.minHeight = `${minH}px`;
371
372 // Set the dirty flag to ensure only a single update occurs.
373 this._dirty = true;
374
375 // Notify the ancestor that it should fit immediately. This may
376 // cause a resize of the parent, fulfilling the required update.
377 if (this.parent!.parent) {
378 MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
379 }
380
381 // If the dirty flag is still set, the parent was not resized.
382 // Trigger the required update on the parent widget immediately.
383 if (this._dirty) {
384 MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
385 }
386 }
387
388 /**
389 * Update the layout position and size of the widgets.
390 *
391 * The parent offset dimensions should be `-1` if unknown.
392 */
393 private _update(offsetWidth: number, offsetHeight: number): void {
394 // Clear the dirty flag to indicate the update occurred.
395 this._dirty = false;
396
397 // Compute the visible item count.
398 let nVisible = 0;
399 for (let i = 0, n = this._items.length; i < n; ++i) {
400 nVisible += +!this._items[i].isHidden;
401 }
402
403 // Bail early if there are no visible items to layout.
404 if (nVisible === 0) {
405 return;
406 }
407
408 // Measure the parent if the offset dimensions are unknown.
409 if (offsetWidth < 0) {
410 offsetWidth = this.parent!.node.offsetWidth;
411 }
412 if (offsetHeight < 0) {
413 offsetHeight = this.parent!.node.offsetHeight;
414 }
415
416 // Ensure the parent box sizing data is computed.
417 if (!this._box) {
418 this._box = ElementExt.boxSizing(this.parent!.node);
419 }
420
421 // Compute the layout area adjusted for border and padding.
422 let top = this._box.paddingTop;
423 let left = this._box.paddingLeft;
424 let width = offsetWidth - this._box.horizontalSum;
425 let height = offsetHeight - this._box.verticalSum;
426
427 // Distribute the layout space and adjust the start position.
428 let delta: number;
429 switch (this._direction) {
430 case 'left-to-right':
431 delta = BoxEngine.calc(this._sizers, Math.max(0, width - this._fixed));
432 break;
433 case 'top-to-bottom':
434 delta = BoxEngine.calc(this._sizers, Math.max(0, height - this._fixed));
435 break;
436 case 'right-to-left':
437 delta = BoxEngine.calc(this._sizers, Math.max(0, width - this._fixed));
438 left += width;
439 break;
440 case 'bottom-to-top':
441 delta = BoxEngine.calc(this._sizers, Math.max(0, height - this._fixed));
442 top += height;
443 break;
444 default:
445 throw 'unreachable';
446 }
447
448 // Setup the variables for justification and alignment offset.
449 let extra = 0;
450 let offset = 0;
451
452 // Account for alignment if there is extra layout space.
453 if (delta > 0) {
454 switch (this._alignment) {
455 case 'start':
456 break;
457 case 'center':
458 extra = 0;
459 offset = delta / 2;
460 break;
461 case 'end':
462 extra = 0;
463 offset = delta;
464 break;
465 case 'justify':
466 extra = delta / nVisible;
467 offset = 0;
468 break;
469 default:
470 throw 'unreachable';
471 }
472 }
473
474 // Layout the items using the computed box sizes.
475 for (let i = 0, n = this._items.length; i < n; ++i) {
476 // Fetch the item.
477 let item = this._items[i];
478
479 // Ignore hidden items.
480 if (item.isHidden) {
481 continue;
482 }
483
484 // Fetch the computed size for the widget.
485 let size = this._sizers[i].size;
486
487 // Update the widget geometry and advance the relevant edge.
488 switch (this._direction) {
489 case 'left-to-right':
490 item.update(left + offset, top, size + extra, height);
491 left += size + extra + this._spacing;
492 break;
493 case 'top-to-bottom':
494 item.update(left, top + offset, width, size + extra);
495 top += size + extra + this._spacing;
496 break;
497 case 'right-to-left':
498 item.update(left - offset - size - extra, top, size + extra, height);
499 left -= size + extra + this._spacing;
500 break;
501 case 'bottom-to-top':
502 item.update(left, top - offset - size - extra, width, size + extra);
503 top -= size + extra + this._spacing;
504 break;
505 default:
506 throw 'unreachable';
507 }
508 }
509 }
510
511 private _fixed = 0;
512 private _spacing = 4;
513 private _dirty = false;
514 private _sizers: BoxSizer[] = [];
515 private _items: LayoutItem[] = [];
516 private _box: ElementExt.IBoxSizing | null = null;
517 private _alignment: BoxLayout.Alignment = 'start';
518 private _direction: BoxLayout.Direction = 'top-to-bottom';
519}
520
521/**
522 * The namespace for the `BoxLayout` class statics.
523 */
524export namespace BoxLayout {
525 /**
526 * A type alias for a box layout direction.
527 */
528 export type Direction =
529 | 'left-to-right'
530 | 'right-to-left'
531 | 'top-to-bottom'
532 | 'bottom-to-top';
533
534 /**
535 * A type alias for a box layout alignment.
536 */
537 export type Alignment = 'start' | 'center' | 'end' | 'justify';
538
539 /**
540 * An options object for initializing a box layout.
541 */
542 export interface IOptions {
543 /**
544 * The direction of the layout.
545 *
546 * The default is `'top-to-bottom'`.
547 */
548 direction?: Direction;
549
550 /**
551 * The content alignment of the layout.
552 *
553 * The default is `'start'`.
554 */
555 alignment?: Alignment;
556
557 /**
558 * The spacing between items in the layout.
559 *
560 * The default is `4`.
561 */
562 spacing?: number;
563 }
564
565 /**
566 * Get the box layout stretch factor for the given widget.
567 *
568 * @param widget - The widget of interest.
569 *
570 * @returns The box layout stretch factor for the widget.
571 */
572 export function getStretch(widget: Widget): number {
573 return Private.stretchProperty.get(widget);
574 }
575
576 /**
577 * Set the box layout stretch factor for the given widget.
578 *
579 * @param widget - The widget of interest.
580 *
581 * @param value - The value for the stretch factor.
582 */
583 export function setStretch(widget: Widget, value: number): void {
584 Private.stretchProperty.set(widget, value);
585 }
586
587 /**
588 * Get the box layout size basis for the given widget.
589 *
590 * @param widget - The widget of interest.
591 *
592 * @returns The box layout size basis for the widget.
593 */
594 export function getSizeBasis(widget: Widget): number {
595 return Private.sizeBasisProperty.get(widget);
596 }
597
598 /**
599 * Set the box layout size basis for the given widget.
600 *
601 * @param widget - The widget of interest.
602 *
603 * @param value - The value for the size basis.
604 */
605 export function setSizeBasis(widget: Widget, value: number): void {
606 Private.sizeBasisProperty.set(widget, value);
607 }
608}
609
610/**
611 * The namespace for the module implementation details.
612 */
613namespace Private {
614 /**
615 * The property descriptor for a widget stretch factor.
616 */
617 export const stretchProperty = new AttachedProperty<Widget, number>({
618 name: 'stretch',
619 create: () => 0,
620 coerce: (owner, value) => Math.max(0, Math.floor(value)),
621 changed: onChildSizingChanged
622 });
623
624 /**
625 * The property descriptor for a widget size basis.
626 */
627 export const sizeBasisProperty = new AttachedProperty<Widget, number>({
628 name: 'sizeBasis',
629 create: () => 0,
630 coerce: (owner, value) => Math.max(0, Math.floor(value)),
631 changed: onChildSizingChanged
632 });
633
634 /**
635 * Test whether a direction has horizontal orientation.
636 */
637 export function isHorizontal(dir: BoxLayout.Direction): boolean {
638 return dir === 'left-to-right' || dir === 'right-to-left';
639 }
640
641 /**
642 * Clamp a spacing value to an integer >= 0.
643 */
644 export function clampSpacing(value: number): number {
645 return Math.max(0, Math.floor(value));
646 }
647
648 /**
649 * The change handler for the attached sizing properties.
650 */
651 function onChildSizingChanged(child: Widget): void {
652 if (child.parent && child.parent.layout instanceof BoxLayout) {
653 child.parent.fit();
654 }
655 }
656}