1 /** 2 * Layout engine — box sizers. 3 * 4 * A `Sizer` arranges child widgets (and nested sizers) within a rectangle. 5 * `HBox` lays children out horizontally, `VBox` vertically. Each child carries 6 * a *proportion*: children with proportion 0 keep their preferred size along the 7 * main axis, while the leftover space is shared among the proportional children 8 * in proportion to their weights. Per-child `Padding` is reserved around the 9 * child's cell. 10 * 11 * The layout math is pure integer arithmetic with no dependency on a running 12 * message loop, so it is exercised directly by unit tests. 13 */ 14 module deft.layout; 15 16 version (Windows): 17 18 import deft.widget : Padding, Rect, Size, Widget; 19 20 /// Horizontal placement of a child within the space available to it. 21 enum HAlign 22 { 23 /// Stretch to fill the width (the default). 24 fill, 25 /// Keep the preferred width, against the left edge. 26 left, 27 /// Keep the preferred width, centered. 28 center, 29 /// Keep the preferred width, against the right edge. 30 right, 31 } 32 33 /// Vertical placement of a child within the space available to it. 34 enum VAlign 35 { 36 /// Stretch to fill the height (the default). 37 fill, 38 /// Keep the preferred height, against the top edge. 39 top, 40 /// Keep the preferred height, centered. 41 middle, 42 /// Keep the preferred height, against the bottom edge. 43 bottom, 44 } 45 46 /// The horizontal offset of a `child`-wide box within `available` for `a`. 47 private int hOffset(HAlign a, int available, int child) @safe pure nothrow @nogc 48 { 49 final switch (a) 50 { 51 case HAlign.fill: 52 case HAlign.left: 53 return 0; 54 case HAlign.center: 55 return (available - child) / 2; 56 case HAlign.right: 57 return available - child; 58 } 59 } 60 61 /// The vertical offset of a `child`-tall box within `available` for `a`. 62 private int vOffset(VAlign a, int available, int child) @safe pure nothrow @nogc 63 { 64 final switch (a) 65 { 66 case VAlign.fill: 67 case VAlign.top: 68 return 0; 69 case VAlign.middle: 70 return (available - child) / 2; 71 case VAlign.bottom: 72 return available - child; 73 } 74 } 75 76 /** 77 * One placed child of a sizer: a widget or a nested sizer, with its proportion, 78 * padding and in-cell alignment. 79 * 80 * Returned by `Sizer.add`/`addSizer` for fluent configuration — chain 81 * `proportion`, `pad`, `alignH`/`alignV` (each returns the same item): 82 * 83 * --- 84 * hbox.add(button).proportion(0).alignV(VAlign.middle).pad(Padding.all(4)); 85 * vbox.add(label).alignH(HAlign.right); 86 * --- 87 * 88 * By default a child fills its cell on both axes. Setting `alignH`/`alignV` to a 89 * non-`fill` value keeps the child's preferred extent on that axis and pins it. 90 */ 91 final class SizerItem 92 { 93 private Widget widget_; 94 private Sizer sizer_; 95 private int proportion_; 96 private Padding padding_; 97 private Size fixedSize_; 98 private HAlign halign_ = HAlign.fill; 99 private VAlign valign_ = VAlign.fill; 100 101 private this(Widget widget, Sizer sizer, int proportion, Padding padding) 102 { 103 widget_ = widget; 104 sizer_ = sizer; 105 proportion_ = proportion; 106 padding_ = padding; 107 } 108 109 /// Set the main-axis weight (0 = keep the preferred size, non-stretching). 110 SizerItem proportion(int weight) return 111 { 112 proportion_ = weight; 113 return this; 114 } 115 116 /** 117 * Keep the child's preferred size on the main axis — it neither grows nor 118 * shrinks as the container resizes. A readable alias for `proportion(0)` 119 * (the default), so `add(button).fixed()` states the intent at the call site. 120 */ 121 SizerItem fixed() return 122 { 123 proportion_ = 0; 124 return this; 125 } 126 127 /** 128 * Make the child grow to share the container's leftover space, with the given 129 * `weight` (default 1). A readable alias for `proportion(weight)`: two 130 * `stretch()` children split the slack evenly, `stretch(2)` versus `stretch(1)` 131 * splits it 2:1. So `add(list).stretch()` reads as "the list takes the slack." 132 */ 133 SizerItem stretch(int weight = 1) return 134 { 135 proportion_ = weight < 1 ? 1 : weight; 136 return this; 137 } 138 139 /// Reserve padding around the child within its cell. 140 SizerItem pad(Padding padding) return 141 { 142 padding_ = padding; 143 return this; 144 } 145 146 /// Set the horizontal placement within the cell. 147 SizerItem alignH(HAlign horizontal) return 148 { 149 halign_ = horizontal; 150 return this; 151 } 152 153 /// Set the vertical placement within the cell. 154 SizerItem alignV(VAlign vertical) return 155 { 156 valign_ = vertical; 157 return this; 158 } 159 160 /** 161 * Override the child's content size with a fixed size that wins over its 162 * preferred size. This is an exact override, not a lower bound — the name says 163 * "fixed," not "minimum." Prefer `autoSize`/`stretch` and reach for this only 164 * for things with an inherently fixed extent (an icon, a fixed-width sidebar). 165 */ 166 SizerItem fixedSize(Size size) return 167 { 168 fixedSize_ = size; 169 return this; 170 } 171 172 /// The child's content size, before padding. 173 private Size contentSize() 174 { 175 if (fixedSize_ != Size.init) 176 return fixedSize_; 177 if (widget_ !is null) 178 return widget_.getPreferredSize(); 179 if (sizer_ !is null) 180 return sizer_.preferredSize(); 181 return Size.init; 182 } 183 184 /// The child's content size plus its padding. 185 Size outerSize() 186 { 187 auto c = contentSize(); 188 return Size( 189 c.width + padding_.left + padding_.right, 190 c.height + padding_.top + padding_.bottom); 191 } 192 193 /// Place the child into `cell`, insetting by padding and applying alignment. 194 void place(Rect cell) 195 { 196 int innerX = cell.x + padding_.left; 197 int innerY = cell.y + padding_.top; 198 int innerW = cell.width - padding_.left - padding_.right; 199 int innerH = cell.height - padding_.top - padding_.bottom; 200 if (innerW < 0) 201 innerW = 0; 202 if (innerH < 0) 203 innerH = 0; 204 205 int x = innerX, y = innerY, w = innerW, h = innerH; 206 207 if (halign_ != HAlign.fill || valign_ != VAlign.fill) 208 { 209 auto content = contentSize(); 210 if (halign_ != HAlign.fill) 211 { 212 w = content.width < innerW ? content.width : innerW; 213 x = innerX + hOffset(halign_, innerW, w); 214 } 215 if (valign_ != VAlign.fill) 216 { 217 h = content.height < innerH ? content.height : innerH; 218 y = innerY + vOffset(valign_, innerH, h); 219 } 220 } 221 222 if (widget_ !is null) 223 widget_.setBounds(Rect(x, y, w, h)); 224 else if (sizer_ !is null) 225 sizer_.layout(Rect(x, y, w, h)); 226 } 227 } 228 229 /// Abstract base for box sizers. 230 abstract class Sizer 231 { 232 protected SizerItem[] items; 233 234 /// Arrange the children within `availableArea`. 235 abstract void layout(Rect availableArea); 236 237 /// The natural size this sizer would like, given its children. 238 abstract Size preferredSize(); 239 240 /** 241 * Add a widget child and return its `SizerItem` for fluent configuration: 242 * `box.add(w).proportion(1).pad(Padding.all(8)).alignV(VAlign.middle)`. A bare 243 * `add(w)` gives a non-stretching child (proportion 0) with no padding. 244 */ 245 SizerItem add(Widget widget) 246 { 247 auto item = new SizerItem(widget, null, 0, Padding.init); 248 items ~= item; 249 return item; 250 } 251 252 /// Add a nested sizer and return its `SizerItem` for fluent configuration. 253 SizerItem addSizer(Sizer sizer) 254 { 255 auto item = new SizerItem(null, sizer, 0, Padding.init); 256 items ~= item; 257 return item; 258 } 259 260 /// Number of child items. 261 size_t length() const @safe pure nothrow @nogc 262 { 263 return items.length; 264 } 265 } 266 267 /// Lays children out left to right. 268 class HBox : Sizer 269 { 270 override void layout(Rect area) 271 { 272 if (items.length == 0) 273 return; 274 275 int totalProp = 0; 276 int fixedMain = 0; 277 ptrdiff_t lastProp = -1; 278 foreach (i, ref it; items) 279 { 280 if (it.proportion_ > 0) 281 { 282 totalProp += it.proportion_; 283 fixedMain += it.padding_.left + it.padding_.right; 284 lastProp = i; 285 } 286 else 287 { 288 fixedMain += it.outerSize().width; 289 } 290 } 291 292 int flexible = area.width - fixedMain; 293 if (flexible < 0) 294 flexible = 0; 295 296 int x = area.x; 297 int allocated = 0; 298 foreach (i, ref it; items) 299 { 300 int outerW; 301 if (it.proportion_ == 0) 302 { 303 outerW = it.outerSize().width; 304 } 305 else 306 { 307 int content; 308 if (i == lastProp) 309 content = flexible - allocated; 310 else 311 { 312 content = flexible * it.proportion_ / totalProp; 313 allocated += content; 314 } 315 outerW = content + it.padding_.left + it.padding_.right; 316 } 317 318 it.place(Rect(x, area.y, outerW, area.height)); 319 x += outerW; 320 } 321 } 322 323 override Size preferredSize() 324 { 325 Size total; 326 foreach (ref it; items) 327 { 328 auto s = it.outerSize(); 329 total.width += s.width; 330 if (s.height > total.height) 331 total.height = s.height; 332 } 333 return total; 334 } 335 } 336 337 /// Lays children out top to bottom. 338 class VBox : Sizer 339 { 340 override void layout(Rect area) 341 { 342 if (items.length == 0) 343 return; 344 345 int totalProp = 0; 346 int fixedMain = 0; 347 ptrdiff_t lastProp = -1; 348 foreach (i, ref it; items) 349 { 350 if (it.proportion_ > 0) 351 { 352 totalProp += it.proportion_; 353 fixedMain += it.padding_.top + it.padding_.bottom; 354 lastProp = i; 355 } 356 else 357 { 358 fixedMain += it.outerSize().height; 359 } 360 } 361 362 int flexible = area.height - fixedMain; 363 if (flexible < 0) 364 flexible = 0; 365 366 int y = area.y; 367 int allocated = 0; 368 foreach (i, ref it; items) 369 { 370 int outerH; 371 if (it.proportion_ == 0) 372 { 373 outerH = it.outerSize().height; 374 } 375 else 376 { 377 int content; 378 if (i == lastProp) 379 content = flexible - allocated; 380 else 381 { 382 content = flexible * it.proportion_ / totalProp; 383 allocated += content; 384 } 385 outerH = content + it.padding_.top + it.padding_.bottom; 386 } 387 388 it.place(Rect(area.x, y, area.width, outerH)); 389 y += outerH; 390 } 391 } 392 393 override Size preferredSize() 394 { 395 Size total; 396 foreach (ref it; items) 397 { 398 auto s = it.outerSize(); 399 total.height += s.height; 400 if (s.width > total.width) 401 total.width = s.width; 402 } 403 return total; 404 } 405 } 406 407 /// How a grid track (one column or one row) is sized. 408 enum GridTrackKind 409 { 410 /// Size to the largest preferred size of the cells in the track. 411 autoSize, 412 /// A fixed size in device pixels. 413 absolute, 414 /// A weighted share of the space left after auto and absolute tracks. 415 percent, 416 } 417 418 /** 419 * The size rule for one column or row of a `Grid`. 420 * 421 * Build with the factory helpers: `GridTrack.autoSize` (fit the content), 422 * `GridTrack.pixels(n)` (a fixed width/height), or `GridTrack.percent(w)` (a 423 * weighted share of the leftover space — the weights of all percent tracks are 424 * summed, so two `percent(50)` tracks split the remainder evenly, exactly like 425 * two `percent(1)` tracks would). 426 */ 427 struct GridTrack 428 { 429 /// The sizing rule. 430 GridTrackKind kind; 431 432 /// Pixels for `absolute`, weight for `percent`, ignored for `autoSize`. 433 int value; 434 435 /// A track sized to its content. 436 static GridTrack autoSize() @safe pure nothrow @nogc 437 { 438 return GridTrack(GridTrackKind.autoSize, 0); 439 } 440 441 /// A track of fixed pixel size. 442 static GridTrack pixels(int px) @safe pure nothrow @nogc 443 { 444 return GridTrack(GridTrackKind.absolute, px); 445 } 446 447 /// A track taking a `weight`-proportioned share of the leftover space. 448 static GridTrack percent(int weight) @safe pure nothrow @nogc 449 { 450 return GridTrack(GridTrackKind.percent, weight); 451 } 452 } 453 454 /** 455 * A placed grid child, returned by `Grid.add`/`Grid.addSizer` for fluent 456 * configuration: 457 * 458 * --- 459 * grid.add(banner, 0, 0).span(2, 1); 460 * grid.add(label, 0, 1).aligned(HAlign.right, VAlign.middle).pad(Padding.all(4)); 461 * --- 462 * 463 * Every modifier returns the same `GridItem`, so calls chain. The grid reads the 464 * item's final state at layout time, so configuration may continue after `add`. 465 * Alignment uses the same `HAlign`/`VAlign` as the box sizers. 466 */ 467 final class GridItem 468 { 469 private SizerItem item; 470 private int column; 471 private int row; 472 private int columnSpan = 1; 473 private int rowSpan = 1; 474 475 /// Make the child cover `columns` columns and `rows` rows from its cell. 476 GridItem span(int columns, int rows = 1) return 477 { 478 columnSpan = columns < 1 ? 1 : columns; 479 rowSpan = rows < 1 ? 1 : rows; 480 return this; 481 } 482 483 /// Set horizontal and vertical alignment within the cell. 484 GridItem aligned(HAlign horizontal, VAlign vertical) return 485 { 486 item.alignH(horizontal); 487 item.alignV(vertical); 488 return this; 489 } 490 491 /// Set the horizontal alignment within the cell. 492 GridItem alignH(HAlign horizontal) return 493 { 494 item.alignH(horizontal); 495 return this; 496 } 497 498 /// Set the vertical alignment within the cell. 499 GridItem alignV(VAlign vertical) return 500 { 501 item.alignV(vertical); 502 return this; 503 } 504 505 /// Reserve padding around the child inside its cell. 506 GridItem pad(Padding padding) return 507 { 508 item.pad(padding); 509 return this; 510 } 511 } 512 513 /** 514 * A table layout: a fixed grid of columns and rows, each independently sized to 515 * its content (`autoSize`), a fixed pixel size (`absolute`) or a weighted share 516 * of the leftover space (`percent`). Widgets and nested sizers are placed into 517 * cells by column/row and may span several columns or rows. Within its cell a 518 * child fills the available space by default, or keeps its preferred size and 519 * aligns (start/center/end) — see `GridItem`. 520 * 521 * This is Deft's analog of WinForms' `TableLayoutPanel`: pick the column and row 522 * counts, mark each track auto or percent (or pixels), and drop children into 523 * cells without computing any coordinates. `add` returns a `GridItem` whose 524 * fluent `span`/`aligned`/`pad` methods read better than positional arguments: 525 * 526 * --- 527 * auto grid = new Grid(2, 2); 528 * grid.setColumn(0, GridTrack.autoSize); 529 * grid.setColumn(1, GridTrack.percent(100)); 530 * grid.add(label, 0, 0).aligned(HAlign.right, VAlign.middle); 531 * grid.add(field, 1, 0); // fills its cell 532 * grid.add(footer, 0, 1).span(2, 1); // spans both columns 533 * --- 534 * 535 * Tracks default to `autoSize`. Auto track sizes are measured from the cells 536 * that do not span (a spanning child is placed across the already-computed 537 * tracks but does not enlarge them). 538 */ 539 class Grid : Sizer 540 { 541 private GridItem[] cells_; 542 private GridTrack[] columns_; 543 private GridTrack[] rows_; 544 private int hgap_; 545 private int vgap_; 546 547 /// Create a grid with `columns` columns and `rows` rows, all `autoSize`. 548 this(int columns, int rows) 549 { 550 if (columns < 0) 551 columns = 0; 552 if (rows < 0) 553 rows = 0; 554 columns_ = new GridTrack[columns]; 555 rows_ = new GridTrack[rows]; 556 foreach (ref c; columns_) 557 c = GridTrack.autoSize; 558 foreach (ref r; rows_) 559 r = GridTrack.autoSize; 560 } 561 562 /// Number of columns. 563 int columnCount() const @safe pure nothrow @nogc 564 { 565 return cast(int) columns_.length; 566 } 567 568 /// Number of rows. 569 int rowCount() const @safe pure nothrow @nogc 570 { 571 return cast(int) rows_.length; 572 } 573 574 /// Set the sizing rule for column `index`. 575 void setColumn(int index, GridTrack track) 576 { 577 if (index >= 0 && index < columns_.length) 578 columns_[index] = track; 579 } 580 581 /// Set the sizing rule for row `index`. 582 void setRow(int index, GridTrack track) 583 { 584 if (index >= 0 && index < rows_.length) 585 rows_[index] = track; 586 } 587 588 /// Set the pixel gap between columns (`horizontal`) and rows (`vertical`). 589 void setSpacing(int horizontal, int vertical) 590 { 591 hgap_ = horizontal < 0 ? 0 : horizontal; 592 vgap_ = vertical < 0 ? 0 : vertical; 593 } 594 595 /** 596 * Place `widget` in the cell at `column`/`row`. Returns a `GridItem` whose 597 * fluent `span`/`aligned`/`alignH`/`alignV`/`pad` methods configure it. 598 */ 599 GridItem add(Widget widget, int column, int row) 600 { 601 auto cell = new GridItem; 602 cell.item = new SizerItem(widget, null, 0, Padding.init); 603 cell.column = column; 604 cell.row = row; 605 cells_ ~= cell; 606 return cell; 607 } 608 609 /// Place a nested `sizer` in the cell at `column`/`row`; see `add`. 610 GridItem addSizer(Sizer sizer, int column, int row) 611 { 612 auto cell = new GridItem; 613 cell.item = new SizerItem(null, sizer, 0, Padding.init); 614 cell.column = column; 615 cell.row = row; 616 cells_ ~= cell; 617 return cell; 618 } 619 620 /// Number of placed children. 621 override size_t length() const @safe pure nothrow @nogc 622 { 623 return cells_.length; 624 } 625 626 override void layout(Rect area) 627 { 628 if (columns_.length == 0 || rows_.length == 0) 629 return; 630 631 auto colSizes = resolveTracks(columns_, area.width, hgap_, true); 632 auto rowSizes = resolveTracks(rows_, area.height, vgap_, false); 633 634 auto colOffsets = trackOffsets(colSizes, hgap_, area.x); 635 auto rowOffsets = trackOffsets(rowSizes, vgap_, area.y); 636 637 foreach (c; cells_) 638 { 639 if (c.column < 0 || c.row < 0 640 || c.column >= columns_.length || c.row >= rows_.length) 641 continue; 642 643 immutable int lastCol = spanEnd(c.column, c.columnSpan, cast(int) columns_.length); 644 immutable int lastRow = spanEnd(c.row, c.rowSpan, cast(int) rows_.length); 645 646 immutable int x = colOffsets[c.column]; 647 immutable int y = rowOffsets[c.row]; 648 immutable int w = colOffsets[lastCol] + colSizes[lastCol] - x; 649 immutable int h = rowOffsets[lastRow] + rowSizes[lastRow] - y; 650 651 // SizerItem.place applies the item's padding and alignment. 652 c.item.place(Rect(x, y, w, h)); 653 } 654 } 655 656 override Size preferredSize() 657 { 658 Size total; 659 foreach (i; 0 .. columns_.length) 660 total.width += preferredTrackSize(columns_[i], cast(int) i, true); 661 foreach (i; 0 .. rows_.length) 662 total.height += preferredTrackSize(rows_[i], cast(int) i, false); 663 if (columns_.length > 1) 664 total.width += hgap_ * (cast(int) columns_.length - 1); 665 if (rows_.length > 1) 666 total.height += vgap_ * (cast(int) rows_.length - 1); 667 return total; 668 } 669 670 /// The largest preferred extent of the non-spanning cells in a track. 671 private int autoTrackSize(int index, bool horizontal) 672 { 673 int best; 674 foreach (c; cells_) 675 { 676 immutable int span = horizontal ? c.columnSpan : c.rowSpan; 677 immutable int at = horizontal ? c.column : c.row; 678 if (span != 1 || at != index) 679 continue; 680 auto s = c.item.outerSize(); 681 immutable int extent = horizontal ? s.width : s.height; 682 if (extent > best) 683 best = extent; 684 } 685 return best; 686 } 687 688 /// The size a track contributes to `preferredSize` (percent uses its content). 689 private int preferredTrackSize(GridTrack track, int index, bool horizontal) 690 { 691 final switch (track.kind) 692 { 693 case GridTrackKind.absolute: 694 return track.value; 695 case GridTrackKind.autoSize: 696 case GridTrackKind.percent: 697 return autoTrackSize(index, horizontal); 698 } 699 } 700 701 /// Resolve every track to a concrete pixel size within `available`. 702 private int[] resolveTracks(GridTrack[] tracks, int available, int gap, 703 bool horizontal) 704 { 705 immutable int n = cast(int) tracks.length; 706 auto sizes = new int[n]; 707 708 int used = n > 1 ? gap * (n - 1) : 0; 709 int totalPercent; 710 int lastPercent = -1; 711 foreach (i, t; tracks) 712 { 713 final switch (t.kind) 714 { 715 case GridTrackKind.absolute: 716 sizes[i] = t.value; 717 used += sizes[i]; 718 break; 719 case GridTrackKind.autoSize: 720 sizes[i] = autoTrackSize(cast(int) i, horizontal); 721 used += sizes[i]; 722 break; 723 case GridTrackKind.percent: 724 totalPercent += t.value; 725 lastPercent = cast(int) i; 726 break; 727 } 728 } 729 730 int remaining = available - used; 731 if (remaining < 0) 732 remaining = 0; 733 734 int allocated; 735 foreach (i, t; tracks) 736 { 737 if (t.kind != GridTrackKind.percent) 738 continue; 739 int size; 740 if (cast(int) i == lastPercent) 741 size = remaining - allocated; // last gets the rounding remainder 742 else 743 { 744 size = totalPercent > 0 ? remaining * t.value / totalPercent : 0; 745 allocated += size; 746 } 747 sizes[i] = size; 748 } 749 return sizes; 750 } 751 752 /// Prefix offsets (in screen/parent coordinates) for a resolved track list. 753 private static int[] trackOffsets(int[] sizes, int gap, int origin) 754 { 755 auto offsets = new int[sizes.length]; 756 int pos = origin; 757 foreach (i, s; sizes) 758 { 759 offsets[i] = pos; 760 pos += s + gap; 761 } 762 return offsets; 763 } 764 765 /// The index of the last track a span covers, clamped to the track count. 766 private static int spanEnd(int start, int span, int count) 767 { 768 int last = start + span - 1; 769 if (last >= count) 770 last = count - 1; 771 if (last < start) 772 last = start; 773 return last; 774 } 775 } 776 777 version (unittest) 778 { 779 /// A headless widget for layout tests: no native handle, records its bounds. 780 private final class FakeWidget : Widget 781 { 782 private Size pref; 783 784 this(Size preferred) 785 { 786 pref = preferred; 787 } 788 789 override Size getPreferredSize() 790 { 791 return pref; 792 } 793 } 794 } 795 796 unittest 797 { 798 // A single proportional child fills the available area. 799 auto w = new FakeWidget(Size(10, 10)); 800 auto box = new VBox(); 801 box.add(w).proportion(1); 802 box.layout(Rect(0, 0, 100, 50)); 803 assert(w.bounds == Rect(0, 0, 100, 50)); 804 } 805 806 unittest 807 { 808 // Two equal-proportion children split the width evenly. 809 auto a = new FakeWidget(Size(0, 0)); 810 auto b = new FakeWidget(Size(0, 0)); 811 auto box = new HBox(); 812 box.add(a).proportion(1); 813 box.add(b).proportion(1); 814 box.layout(Rect(0, 0, 100, 20)); 815 assert(a.bounds == Rect(0, 0, 50, 20)); 816 assert(b.bounds == Rect(50, 0, 50, 20)); 817 } 818 819 unittest 820 { 821 // A 2:1 proportion ratio splits 90px into 60 and 30. 822 auto a = new FakeWidget(Size(0, 0)); 823 auto b = new FakeWidget(Size(0, 0)); 824 auto box = new HBox(); 825 box.add(a).proportion(2); 826 box.add(b).proportion(1); 827 box.layout(Rect(0, 0, 90, 10)); 828 assert(a.bounds == Rect(0, 0, 60, 10)); 829 assert(b.bounds == Rect(60, 0, 30, 10)); 830 } 831 832 unittest 833 { 834 // A fixed (proportion 0) child keeps its preferred size; the proportional 835 // child takes the rest. 836 auto fixed = new FakeWidget(Size(30, 10)); 837 auto flex = new FakeWidget(Size(0, 0)); 838 auto box = new HBox(); 839 box.add(fixed); 840 box.add(flex).proportion(1); 841 box.layout(Rect(0, 0, 100, 10)); 842 assert(fixed.bounds == Rect(0, 0, 30, 10)); 843 assert(flex.bounds == Rect(30, 0, 70, 10)); 844 } 845 846 unittest 847 { 848 // Nested sizers: a VBox inside an HBox lays out within its allotted column. 849 auto left = new FakeWidget(Size(0, 0)); 850 auto top = new FakeWidget(Size(0, 0)); 851 auto bottom = new FakeWidget(Size(0, 0)); 852 853 auto inner = new VBox(); 854 inner.add(top).proportion(1); 855 inner.add(bottom).proportion(1); 856 857 auto outer = new HBox(); 858 outer.add(left).proportion(1); 859 outer.addSizer(inner).proportion(1); 860 861 outer.layout(Rect(0, 0, 100, 40)); 862 863 // Left column: x 0..50. 864 assert(left.bounds == Rect(0, 0, 50, 40)); 865 // Right column (the VBox) occupies x 50..100 and stacks its children. 866 assert(top.bounds == Rect(50, 0, 50, 20)); 867 assert(bottom.bounds == Rect(50, 20, 50, 20)); 868 } 869 870 unittest 871 { 872 // Padding is reserved around the child. 873 auto w = new FakeWidget(Size(0, 0)); 874 auto box = new VBox(); 875 box.add(w).proportion(1).pad(Padding.all(5)); 876 box.layout(Rect(0, 0, 100, 100)); 877 assert(w.bounds == Rect(5, 5, 90, 90)); 878 } 879 880 unittest 881 { 882 // Zero available space must not crash and yields empty bounds. 883 auto w = new FakeWidget(Size(0, 0)); 884 auto box = new HBox(); 885 box.add(w).proportion(1); 886 box.layout(Rect(0, 0, 0, 0)); 887 assert(w.bounds == Rect(0, 0, 0, 0)); 888 } 889 890 unittest 891 { 892 // An empty sizer has a zero preferred size and lays out without error. 893 auto box = new VBox(); 894 assert(box.preferredSize() == Size(0, 0)); 895 box.layout(Rect(0, 0, 100, 100)); 896 897 // preferredSize sums the main axis and maxes the cross axis. 898 auto h = new HBox(); 899 h.add(new FakeWidget(Size(20, 8))); 900 h.add(new FakeWidget(Size(30, 12))); 901 assert(h.preferredSize() == Size(50, 12)); 902 } 903 904 unittest 905 { 906 // Grid: two 50% columns and one 100% row split the width evenly and fill it. 907 auto a = new FakeWidget(Size(10, 10)); 908 auto b = new FakeWidget(Size(10, 10)); 909 auto grid = new Grid(2, 1); 910 grid.setColumn(0, GridTrack.percent(50)); 911 grid.setColumn(1, GridTrack.percent(50)); 912 grid.setRow(0, GridTrack.percent(100)); 913 grid.add(a, 0, 0); 914 grid.add(b, 1, 0); 915 grid.layout(Rect(0, 0, 100, 40)); 916 assert(a.bounds == Rect(0, 0, 50, 40)); 917 assert(b.bounds == Rect(50, 0, 50, 40)); 918 } 919 920 unittest 921 { 922 // Grid: an auto column keeps its content width; a percent column takes the 923 // rest. Two auto rows stack at their content heights. 924 auto label = new FakeWidget(Size(30, 12)); 925 auto field = new FakeWidget(Size(0, 12)); 926 auto grid = new Grid(2, 1); 927 grid.setColumn(0, GridTrack.autoSize); 928 grid.setColumn(1, GridTrack.percent(100)); 929 grid.setRow(0, GridTrack.percent(100)); 930 grid.add(label, 0, 0); 931 grid.add(field, 1, 0); 932 grid.layout(Rect(0, 0, 200, 20)); 933 assert(label.bounds == Rect(0, 0, 30, 20)); 934 assert(field.bounds == Rect(30, 0, 170, 20)); 935 } 936 937 unittest 938 { 939 // Grid: absolute pixel column, padding inside a cell, and spacing between 940 // columns are all honored. 941 auto a = new FakeWidget(Size(0, 0)); 942 auto b = new FakeWidget(Size(0, 0)); 943 auto grid = new Grid(2, 1); 944 grid.setColumn(0, GridTrack.pixels(40)); 945 grid.setColumn(1, GridTrack.percent(100)); 946 grid.setRow(0, GridTrack.percent(100)); 947 grid.setSpacing(10, 0); 948 grid.add(a, 0, 0); 949 grid.add(b, 1, 0).pad(Padding.all(5)); 950 grid.layout(Rect(0, 0, 100, 30)); 951 // Column 0 = 40px, 10px gap, column 1 = remaining 50px starting at x=50. 952 assert(a.bounds == Rect(0, 0, 40, 30)); 953 assert(b.bounds == Rect(55, 5, 40, 20)); 954 } 955 956 unittest 957 { 958 // Grid: a child spanning two columns covers both tracks plus the gap between. 959 auto wide = new FakeWidget(Size(0, 0)); 960 auto grid = new Grid(2, 2); 961 grid.setColumn(0, GridTrack.percent(50)); 962 grid.setColumn(1, GridTrack.percent(50)); 963 grid.setRow(0, GridTrack.percent(50)); 964 grid.setRow(1, GridTrack.percent(50)); 965 grid.setSpacing(10, 10); 966 grid.add(wide, 0, 0).span(2, 1); 967 grid.layout(Rect(0, 0, 210, 110)); 968 // Each column = (210-10)/2 = 100; the span covers 100 + 10 gap + 100 = 210. 969 // Each row = (110-10)/2 = 50. 970 assert(wide.bounds == Rect(0, 0, 210, 50)); 971 } 972 973 unittest 974 { 975 // Grid: preferredSize sums absolute/auto track sizes plus spacing; a degenerate 976 // grid lays out without crashing. 977 auto grid = new Grid(2, 1); 978 grid.setColumn(0, GridTrack.pixels(40)); 979 grid.setColumn(1, GridTrack.autoSize); 980 grid.setRow(0, GridTrack.autoSize); 981 grid.setSpacing(8, 0); 982 grid.add(new FakeWidget(Size(0, 0)), 0, 0); 983 grid.add(new FakeWidget(Size(25, 16)), 1, 0); 984 assert(grid.preferredSize() == Size(40 + 25 + 8, 16)); 985 986 auto empty = new Grid(0, 0); 987 empty.layout(Rect(0, 0, 100, 100)); // must not crash 988 assert(empty.length == 0); 989 } 990 991 unittest 992 { 993 // Grid alignment: a fixed-size child centered in a larger cell keeps its size 994 // and sits in the middle; fill (the default) stretches. 995 auto centered = new FakeWidget(Size(20, 10)); 996 auto grid = new Grid(1, 1); 997 grid.setColumn(0, GridTrack.percent(100)); 998 grid.setRow(0, GridTrack.percent(100)); 999 grid.add(centered, 0, 0).aligned(HAlign.center, VAlign.middle); 1000 grid.layout(Rect(0, 0, 100, 50)); 1001 assert(centered.bounds == Rect(40, 20, 20, 10)); // (100-20)/2, (50-10)/2 1002 1003 // right/top alignment pins to the right/top edges. 1004 auto pinned = new FakeWidget(Size(20, 10)); 1005 auto g2 = new Grid(1, 1); 1006 g2.setColumn(0, GridTrack.percent(100)); 1007 g2.setRow(0, GridTrack.percent(100)); 1008 g2.add(pinned, 0, 0).alignH(HAlign.right).alignV(VAlign.top); 1009 g2.layout(Rect(0, 0, 100, 50)); 1010 assert(pinned.bounds == Rect(80, 0, 20, 10)); 1011 } 1012 1013 unittest 1014 { 1015 // Fluent box placement: proportion set via the returned handle matches the 1016 // positional form (2:1 split of 90px into 60 and 30). 1017 auto a = new FakeWidget(Size(0, 0)); 1018 auto b = new FakeWidget(Size(0, 0)); 1019 auto box = new HBox(); 1020 box.add(a).proportion(2); 1021 box.add(b).proportion(1); 1022 box.layout(Rect(0, 0, 90, 10)); 1023 assert(a.bounds == Rect(0, 0, 60, 10)); 1024 assert(b.bounds == Rect(60, 0, 30, 10)); 1025 } 1026 1027 unittest 1028 { 1029 // fixed()/stretch() aliases match the equivalent proportion() forms: a fixed 1030 // child keeps its preferred width and the stretch child takes the rest. 1031 auto fixedW = new FakeWidget(Size(30, 10)); 1032 auto flex = new FakeWidget(Size(0, 0)); 1033 auto box = new HBox(); 1034 box.add(fixedW).fixed(); 1035 box.add(flex).stretch(); 1036 box.layout(Rect(0, 0, 100, 10)); 1037 assert(fixedW.bounds == Rect(0, 0, 30, 10)); 1038 assert(flex.bounds == Rect(30, 0, 70, 10)); 1039 1040 // Weighted stretch: stretch(2) versus stretch(1) splits 90px into 60 and 30, 1041 // and stretch(0) is clamped up to weight 1 (it must still grow). 1042 auto a = new FakeWidget(Size(0, 0)); 1043 auto b = new FakeWidget(Size(0, 0)); 1044 auto two = new HBox(); 1045 two.add(a).stretch(2); 1046 two.add(b).stretch(0); // clamped to 1 1047 two.layout(Rect(0, 0, 90, 10)); 1048 assert(a.bounds == Rect(0, 0, 60, 10)); 1049 assert(b.bounds == Rect(60, 0, 30, 10)); 1050 } 1051 1052 unittest 1053 { 1054 // HBox cross-axis (vertical) alignment: a fixed-height child fills the width 1055 // (proportion 1) but is vertically centered at its preferred height. 1056 auto w = new FakeWidget(Size(20, 10)); 1057 auto box = new HBox(); 1058 box.add(w).proportion(1).alignV(VAlign.middle); 1059 box.layout(Rect(0, 0, 100, 50)); 1060 assert(w.bounds == Rect(0, 20, 100, 10)); // y = (50-10)/2 1061 1062 // VBox cross-axis (horizontal) alignment: right-pinned at preferred width. 1063 auto w2 = new FakeWidget(Size(20, 10)); 1064 auto vb = new VBox(); 1065 vb.add(w2).proportion(1).alignH(HAlign.right); 1066 vb.layout(Rect(0, 0, 100, 50)); 1067 assert(w2.bounds == Rect(80, 0, 20, 50)); // x = 100-20 1068 }