lib.easing
1from typing import Literal, Callable 2from math import pi, cos, sin, sqrt, ceil 3from loguru import logger 4from lib import layout, helpers 5 6 7# Easing functions 8# region 9_c1 = 1.70158 10_c2 = _c1 * 1.525 11_c3 = _c1 + 1 12_c4 = (2 * pi) / 3 13_c5 = (2 * pi) / 4.5 14 15 16def linear(x: float) -> float: 17 return x 18 19 20def easeInQuad(x: float) -> float: 21 return x * x 22 23 24def easeOutQuad(x: float) -> float: 25 return 1 - (1 - x) * (1 - x) 26 27 28def easeInOutQuad(x: float) -> float: 29 return 2 * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 2) / 2 30 31 32def easeInCubic(x: float) -> float: 33 return x * x * x 34 35 36def easeOutCubic(x: float) -> float: 37 return 1 - pow(1 - x, 3) 38 39 40def easeInOutCubic(x: float) -> float: 41 return 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2 42 43 44def easeInQuart(x: float) -> float: 45 return x * x * x * x 46 47 48def easeOutQuart(x: float) -> float: 49 return 1 - pow(1 - x, 4) 50 51 52def easeInOutQuart(x: float) -> float: 53 return 8 * x * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 4) / 2 54 55 56def easeInQuint(x: float) -> float: 57 return x * x * x * x * x 58 59 60def easeOutQuint(x: float) -> float: 61 return 1 - pow(1 - x, 5) 62 63 64def easeInOutQuint(x: float) -> float: 65 return 16 * x * x * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 5) / 2 66 67 68def easeInSine(x: float) -> float: 69 return 1 - cos((x * pi) / 2) 70 71 72def easeOutSine(x: float) -> float: 73 return sin((x * pi) / 2) 74 75 76def easeInOutSine(x: float) -> float: 77 return -(cos(pi * x) - 1) / 2 78 79 80def easeInExpo(x: float) -> float: 81 return 0 if x == 0 else pow(2, 10 * x - 10) 82 83 84def easeOutExpo(x: float) -> float: 85 return 1 if x == 1 else 1 - pow(2, -10 * x) 86 87 88def easeInOutExpo(x: float) -> float: 89 return ( 90 0 91 if x == 0 92 else ( 93 1 94 if x == 1 95 else pow(2, 20 * x - 10) / 2 96 if x < 0.5 97 else (2 - pow(2, -20 * x + 10)) 98 ) 99 ) 100 101 102def easeInCirc(x: float) -> float: 103 return 1 - sqrt(1 - pow(x, 2)) 104 105 106def easeOutCirc(x: float) -> float: 107 return sqrt(1 - pow(x - 1, 2)) 108 109 110def easeInOutCirc(x: float) -> float: 111 return ( 112 (1 - sqrt(1 - pow(2 * x, 2))) / 2 113 if x < 0.5 114 else (sqrt(1 - pow(-2 * x + 2, 2)) + 1) / 2 115 ) 116 117 118def easeInBack(x: float) -> float: 119 return _c3 * pow(x, 3) - _c1 * pow(x, 2) 120 121 122def easeOutBack(x: float) -> float: 123 return 1 + _c3 * pow(x - 1, 3) + _c1 * pow(x - 1, 2) 124 125 126def easeInOutBack(x: float) -> float: 127 return ( 128 (pow(2 * x, 2) * ((_c2 + 1) * 2 * x - _c2)) / 2 129 if x < 0.5 130 else (pow(2 * x - 2, 2) * ((_c2 + 1) * (x * 2 - 2) + _c2) + 2) / 2 131 ) 132 133 134def easeInElastic(x: float) -> float: 135 return ( 136 0 137 if x == 0 138 else 1 139 if x == 1 140 else -pow(2, 10 * x - 10) * sin((x * 10 - 10.75) * _c4) 141 ) 142 143 144def easeOutElastic(x: float) -> float: 145 return ( 146 0 147 if x == 0 148 else 1 149 if x == 1 150 else pow(2, -10 * x) * sin((x * 10 - 0.75) * _c4) + 1 151 ) 152 153 154def easeInOutElastic(x: float) -> float: 155 return ( 156 0 157 if x == 0 158 else ( 159 1 160 if x == 1 161 else ( 162 -(pow(2, 20 * x - 10) * sin((20 * x - 11.125) * _c5)) / 2 163 if x < 0.5 164 else (pow(2, -20 * x + 10) * sin((20 * x - 11.125) * _c5)) / 2 + 1 165 ) 166 ) 167 ) 168 169 170# endregion 171 172 173# Math helpers 174def lerp( 175 start: float | tuple[float, float], stop: float | tuple[float, float], amount: float 176) -> float | tuple[float, float]: 177 """ 178 Linearly interpolates between start and stop by amount. 179 180 Args: 181 start: Start value or tuple. 182 stop: Stop value or tuple. 183 amount: Interpolation factor between 0 and 1. 184 185 Returns: 186 Interpolated value or tuple. 187 188 Example: 189 - `start=0 stop=10 amount=0.5` => 5 190 - `start=(0,0) stop=(10,20) amount=0.5` => (5, 10) 191 """ 192 193 def calc(start, stop): 194 try: 195 return start * (1 - amount) + stop * amount 196 except Exception as e: 197 logger.warning("Lerp failed {} {} {}: {}", start, stop, amount, e) 198 return 0 199 200 if all(isinstance(e, tuple) for e in [start, stop]): 201 return tuple(map(calc, start, stop)) 202 else: 203 return calc(start, stop) 204 205 206def invLerp(start, stop, amount): 207 """ 208 Calculates the normalized value of amount between start and stop. 209 210 Args: 211 start: Start value. 212 stop: Stop value. 213 amount: Value to normalize. 214 215 Returns: 216 Normalized value between 0 and 1. 217 """ 218 return clamp((amount - start) / (stop - start)) 219 220 221def clamp(amount, aMin=0, aMax=1): 222 """Returns amount clamped between aMin and aMax.""" 223 return min(aMax, max(aMin, amount)) 224 225 226def lerpRange(start1, stop1, start2, stop2, amount): 227 """ 228 Maps amount from range [start1, stop1] to [start2, stop2] using linear interpolation. 229 230 Args: 231 start1: Start of input range. 232 stop1: End of input range. 233 start2: Start of output range. 234 stop2: End of output range. 235 amount: Value in input range. 236 237 Returns: 238 Value mapped to output range. 239 """ 240 return lerp(start2, stop2, invLerp(start1, stop1, amount)) 241 242 243def retrieve( 244 curve: Literal["sine", "quad", "cubic", "quart", "quint"], 245 direction: Literal["in", "out"], 246) -> Callable[[float], float]: 247 """ 248 Get easing function on the fly. 249 250 Args: 251 curve: Easing function type. 252 direction: 'in' or 'out'. 253 254 Returns: 255 Corresponding easing function. 256 """ 257 functions = dict( 258 sine=(easeInSine, easeOutSine), 259 quad=(easeInQuad, easeOutQuad), 260 cubic=(easeInCubic, easeOutCubic), 261 quart=(easeInQuart, easeOutQuart), 262 quint=(easeInQuint, easeOutQuint), 263 ) 264 265 funcIn, funcOut = functions.get(curve) 266 return funcIn if direction == "in" else funcOut 267 268 269def _analyzeEvenCycles(steps: int, speed: float) -> tuple[int, int] | None: 270 """Return (cycleCount, cycleSteps) for exact repeated cycles, else None.""" 271 isIntegerSpeed = isinstance(speed, (int, float)) and float(speed).is_integer() 272 if not isIntegerSpeed: 273 return None 274 275 cycleCount = int(speed) 276 hasEvenCycles = cycleCount > 1 and steps > 0 and steps % cycleCount == 0 277 if not hasEvenCycles: 278 return None 279 280 cycleSteps = steps // cycleCount 281 return cycleCount, cycleSteps 282 283 284def interpolate( 285 start: float | tuple[float, ...] = 0, 286 stop: float | tuple[float, ...] = 10, 287 steps: int = 10, 288 offset: float = 0, 289 speed: float = 1, 290 curve: Callable[[float], float] = linear, 291 curveStrength: float = 1, 292 mirror: bool = False, 293 flip: bool = False, 294) -> list[float | tuple[float, ...]]: 295 """ 296 Create n steps with easing. 297 298 Args: 299 start: Start value. 300 stop: Stop value. 301 steps: Number of steps. 302 offset: Time offset. 303 speed: Number of iterations. 304 curve: Easing function to use. 305 curveStrength: Amount of easing applied (0 = None, i.e. `linear`). 306 mirror: If True, mirror the easing. 307 flip: If True, flip start and stop. 308 309 Returns: 310 List of interpolated values. 311 312 Example: 313 - Create n steps with easing => `[0, 0.11, 0.3, 0.7, 1]` 314 - Can be flipped/mirrored: `[0, 0.6, 1, 0.6, 0]` 315 """ 316 317 def _doStep(step: int) -> float | tuple[float, ...]: 318 prog = (step / end) * speed 319 320 if prog % 1: 321 prog = prog % 1 322 else: 323 prog = min(prog, 1) 324 325 if mirror: 326 isSecondHalf = prog > 0.5 327 if isSecondHalf: 328 prog = (end - step) / minorHalf 329 else: 330 prog = step / minorHalf 331 332 progOffset = prog + offset 333 334 if progOffset > 1: 335 progOffset -= 1 336 337 progEase = curve(progOffset) 338 # 0 = linear, 1 = 100% eased 339 progFactored = lerp(progOffset, progEase, abs(curveStrength)) 340 341 value = lerp(start, stop, progFactored) 342 return value 343 344 def _doStepExactCycles(step: int, cycleSteps: int) -> float | tuple[float, ...]: 345 # Include both cycle endpoints, so each cycle reaches min->max exactly. 346 # Example: steps=20, speed=2 => idx 9 == max, idx 10 == min. 347 cycleStep = step % cycleSteps 348 cycleDenominator = max(cycleSteps - 1, 1) 349 prog = cycleStep / cycleDenominator 350 351 if mirror: 352 isSecondHalf = prog > 0.5 353 if isSecondHalf: 354 prog = 1 - prog 355 356 progOffset = prog + offset 357 358 if progOffset > 1: 359 progOffset -= 1 360 361 progEase = curve(progOffset) 362 # 0 = linear, 1 = 100% eased 363 progFactored = lerp(progOffset, progEase, abs(curveStrength)) 364 365 value = lerp(start, stop, progFactored) 366 return value 367 368 if flip: 369 start, stop = stop, start 370 371 end = steps - 1 372 # 5 steps => 2 373 minorHalf = ceil(steps / 2) - 1 or 1 374 375 cycleInfo = _analyzeEvenCycles(steps, speed) 376 if cycleInfo: 377 _, cycleSteps = cycleInfo 378 return [_doStepExactCycles(step, cycleSteps) for step in range(steps)] 379 380 return [_doStep(step) for step in range(steps)] 381 382 383def distributeValue( 384 value: float, 385 steps: int = 10, 386 gap: layout.UnitSource = 0, 387 speed: float = 1, 388 curve=linear, 389 curveStrength: float = 1, 390 mirror: bool = False, 391 flip: bool = False, 392 output: Literal["segments", "bounds"] = "segments", 393) -> list[float]: 394 """Distributes a total value based on easing curves. 395 396 Args: 397 value: Total value to distribute (e.g., width). 398 steps: Number of steps to create. 399 gap: Gap between steps (can be a unit string like '10pt'). 400 speed: Number of interpolation iterations (same behavior as interpolate). 401 curve: Easing function to determine distribution. 402 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve). 403 mirror: If True, mirror the easing curve. 404 flip: If True, flip the easing curve. 405 output: Return segment sizes ("segments") or edge-aligned positions 406 from lower to upper bound ("bounds"). 407 408 Returns: 409 List of segment sizes ("segments") or positions from 0..value 410 including both bounds ("bounds"). 411 412 Example: 413 - Distribute 800 into 3 segments with easing => [500, 200, 100] 414 - With gaps of 10pt => [490, 190, 90] (total 780 + 20 gap = 800) 415 """ 416 if steps <= 0: 417 return [] 418 419 gapCount = max(steps - 1, 0) 420 gapSum = layout.parseUnit(gap) * gapCount 421 availValue = value - gapSum 422 423 # Return empty list if no space is available for segments. 424 if availValue <= 0: 425 return [] 426 427 if steps == 1: 428 if output == "bounds": 429 return [0.0] 430 return [availValue] 431 432 cycleInfo = _analyzeEvenCycles(steps, speed) 433 if cycleInfo: 434 # For exact cycle splits (e.g. steps=8, speed=2), build one cycle and repeat 435 # so each cycle contributes identical segment weights without a boundary seam. 436 cycleCount, cycleSteps = cycleInfo 437 cycleBoundaries = interpolate( 438 start=0, 439 stop=1, 440 steps=cycleSteps + 1, 441 speed=1, 442 curve=curve, 443 mirror=mirror, 444 flip=False, 445 ) 446 cycleWeights = [ 447 abs(stop - start) 448 for start, stop in zip(cycleBoundaries, cycleBoundaries[1:]) 449 ] 450 easedWeights = cycleWeights * cycleCount 451 else: 452 # Build segment weights from boundary intervals so the first segment 453 # starts at the minimum bound (important for linear correctness). 454 boundaries = interpolate( 455 start=0, 456 stop=1, 457 steps=steps + 1, 458 speed=speed, 459 curve=curve, 460 mirror=mirror, 461 flip=False, 462 ) 463 464 # Use interval lengths between consecutive boundaries as raw weights. 465 easedWeights = [ 466 abs(stop - start) for start, stop in zip(boundaries, boundaries[1:]) 467 ] 468 469 if flip: 470 easedWeights.reverse() 471 472 # Avoid zero-width segments when the first eased sample is exactly 0. 473 easedWeights = [weight if weight > 0 else 1e-9 for weight in easedWeights] 474 easedTotal = sum(easedWeights) 475 476 # Degenerate curves (all equal) fall back to equal distribution. 477 if easedTotal == 0: 478 easedWeights = [1.0] * steps 479 easedTotal = float(steps) 480 481 easedNorm = [weight / easedTotal for weight in easedWeights] 482 linearNorm = [1 / steps] * steps 483 484 strength = clamp(curveStrength, 0, 1) 485 normWeights = [ 486 lerp(linear, eased, strength) for linear, eased in zip(linearNorm, easedNorm) 487 ] 488 489 segments = [availValue * weight for weight in normWeights] 490 491 # Keep an exact total despite floating-point rounding. 492 segments[-1] = availValue - sum(segments[:-1]) 493 494 if output == "segments": 495 return segments 496 497 # Bounds mode returns `steps` positions from lower to upper bound. 498 easedBounds = interpolate( 499 start=0, 500 stop=value, 501 steps=steps, 502 speed=speed, 503 curve=curve, 504 mirror=mirror, 505 flip=flip, 506 ) 507 508 linearBounds = interpolate( 509 start=0, 510 stop=value, 511 steps=steps, 512 speed=speed, 513 curve=linear, 514 ) 515 516 bounds = [ 517 lerp(linearValue, easedValue, strength) 518 for linearValue, easedValue in zip(linearBounds, easedBounds) 519 ] 520 521 # Keep exact lower/upper endpoints despite floating-point rounding. 522 bounds[0] = 0.0 523 bounds[-1] = value 524 return bounds 525 526 527def distributeCoords( 528 container: layout.Coordinates, 529 create: layout.LayoutFlow, 530 steps: int = 10, 531 gap: layout.UnitSource = 0, 532 speed: float = 1, 533 curve=linear, 534 curveStrength: float = 1, 535 mirror: bool = False, 536 flip: bool = False, 537 minSize: layout.UnitSource = 0, 538) -> list[layout.Coordinates]: 539 """Distributes coordinates within a container based on easing curves. 540 541 Args: 542 container: Tuple of (x, y, width, height) defining the container. 543 create: "rows" or "columns" to specify distribution mode. 544 steps: Number of rows or columns to create. 545 gap: Gap between rows/columns (can be a unit string like '10pt'). 546 speed: Number of interpolation iterations (same behavior as interpolate). 547 curve: Easing function to determine distribution. 548 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve 549 mirror: If True, mirror the easing curve. 550 flip: If True, flip the easing curve. 551 minSize: Minimum segment size to keep on the distribution axis. 552 In ``create="columns"`` this is a minimum width; in 553 ``create="rows"`` this is a minimum height. Segments below 554 the threshold are removed and survivors are rescaled to fill 555 the container while preserving gaps. Use None to disable filtering. 556 Unsigned % uses the main axis dimension as base. 557 558 Returns: 559 List of coordinates (x, y, width, height) for each distributed row/column. 560 """ 561 containerX, containerY, containerW, containerH = layout.toCoords(container) 562 isCols = create == "columns" 563 isRows = not isCols 564 modeValue = containerW if isCols else containerH 565 566 segments = distributeValue( 567 value=modeValue, 568 steps=steps, 569 gap=gap, 570 speed=speed, 571 curve=curve, 572 curveStrength=curveStrength, 573 mirror=mirror, 574 flip=flip, 575 ) 576 577 output = [] 578 gapValue = layout.parseUnit(gap) 579 580 isUnsignedPct = ( 581 isinstance(minSize, str) 582 and minSize.endswith("%") 583 and not minSize[:-1].startswith(("+", "-")) 584 ) 585 minSizeValue = ( 586 0 587 if minSize is None 588 else ( 589 layout.parseUnit(minSize, base=modeValue) 590 if isUnsignedPct 591 else layout.parseUnit(minSize) 592 ) 593 ) 594 595 if minSizeValue > 0: 596 filteredSegments = [seg for seg in segments if seg >= minSizeValue] 597 if not filteredSegments: 598 return [] 599 if len(filteredSegments) < len(segments): 600 totalGap = (len(filteredSegments) - 1) * gapValue 601 availableValue = modeValue - totalGap 602 totalSurvivor = sum(filteredSegments) 603 scale = availableValue / totalSurvivor if totalSurvivor else 1 604 segments = [seg * scale for seg in filteredSegments] 605 else: 606 segments = filteredSegments 607 608 itemX = containerX 609 # Start from top for rows 610 itemY = containerY + containerH if isRows else containerY 611 612 for seg in segments: 613 itemW = seg if isCols else containerW 614 itemH = seg if isRows else containerH 615 616 if isRows: 617 itemY -= seg 618 619 easedCoords = itemX, itemY, itemW, itemH 620 output.append(easedCoords) 621 622 if isCols: 623 itemX += itemW + gapValue 624 else: 625 itemY -= gapValue 626 627 return output 628 629 630def distributeCoords2D( 631 container: layout.Coordinates, 632 create: layout.LayoutFlow, 633 steps: int = 10, 634 gap: layout.UnitSource = 0, 635 curve=linear, 636 curveStrength: float = 0.5, 637 crossStart: float = 1, 638 crossStop: float = 0.5, 639 crossAlign: tuple[layout.AlignX, layout.AlignY] = ("left", "top"), 640 mirror: bool = False, 641 flip: bool = False, 642 minSize: layout.UnitSource = 0, 643) -> list[layout.Coordinates]: 644 """Distributes coordinates in two dimensions within a container based on easing curves. 645 646 Args: 647 container: Tuple of (x, y, width, height) defining the container. 648 create: "rows" or "columns" to specify distribution mode. 649 steps: Number of rows or columns to create. 650 gap: Gap between rows/columns (can be a unit string like '10pt'). 651 curve: Easing function to determine distribution. 652 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve). 653 crossStart: Start value for the cross axis interpolation (0 to 1). 654 crossStop: Stop value for the cross axis interpolation (0 to 1). 655 crossAlign: Alignment of the child boxes on the cross axis. 656 mirror: If True, mirror the easing curve. 657 flip: If True, flip the easing curve. 658 minSize: Minimum segment size on the main distribution axis. 659 In ``create="columns"`` this is a minimum width; in 660 ``create="rows"`` this is a minimum height. Uses the same 661 behavior as distributeCoords. 662 663 Returns: 664 List of coordinates (x, y, width, height) for each distributed box. 665 """ 666 containerW, containerH = layout.toDimensions(container) 667 isCols = create == "columns" 668 669 mainCurve, crossCurve = helpers.expand(curve) 670 671 boxes = distributeCoords( 672 container=container, 673 create=create, 674 steps=steps, 675 gap=gap, 676 curve=mainCurve, 677 curveStrength=curveStrength, 678 mirror=mirror, 679 flip=flip, 680 minSize=minSize, 681 ) 682 683 # Cross axis is perpendicular to distribution axis. 684 targetValue = containerH if isCols else containerW 685 values = interpolate( 686 start=targetValue * crossStart, 687 stop=targetValue * crossStop, 688 steps=len(boxes), 689 curve=crossCurve, 690 mirror=mirror, 691 flip=flip, 692 ) 693 694 # Combine 695 output = [] 696 for [parent, length] in zip(boxes, values): 697 childX, childY, childW, childH = parent 698 699 if isCols: 700 childH = length 701 else: 702 childW = length 703 704 child = childX, childY, childW, childH 705 706 output.append(layout.align(parent=parent, child=child, position=crossAlign)) 707 708 return output 709 710 711def distributeCoordsGrid( 712 container: layout.Coordinates, 713 steps: int | tuple[int, int] = 5, 714 gap: layout.UnitSource | tuple[layout.UnitSource, layout.UnitSource] = 0, 715 curve=linear, 716 curveStrength: float = 1, 717 mirror: bool | tuple[bool, bool] = False, 718 flip: bool | tuple[bool, bool] = False, 719 minSize: layout.UnitSource | tuple[layout.UnitSource, layout.UnitSource] = 0, 720) -> list[layout.Coordinates]: 721 """Generate a grid of coordinates by independently distributing rows and columns. 722 723 Per-axis overrides use tuple unpacking: (column_value, row_value). 724 Single values apply to both axes. 725 726 Args: 727 container: Tuple of (x, y, width, height) defining the container. 728 steps: Number of cells per axis, or (colSteps, rowSteps). 729 gap: Gap between cells, or (colGap, rowGap). 730 curve: Easing function(s) for distribution. 731 curveStrength: Easing strength (0 = linear, 1 = full curve). 732 mirror: Mirror distribution, or (colMirror, rowMirror). 733 flip: Flip distribution, or (colFlip, rowFlip). 734 minSize: Minimum cell size to include, or (minWidth, minHeight) per axis. 735 Columns narrower than minWidth and rows shorter than minHeight are removed; 736 survivors are rescaled to fill the container. Gaps are not scaled. 737 Use None for either axis to skip filtering on that axis, e.g. (None, '10%') 738 applies only a row height minimum. Unsigned % uses the main axis dimension 739 as base (width for columns, height for rows). 740 741 Returns: 742 List of coordinates (x, y, width, height) for each grid cell. 743 """ 744 # Unpack per-axis overrides. 745 colSteps, rowSteps = helpers.expand(steps) 746 colGap, rowGap = helpers.expand(gap) 747 colCurve, rowCurve = helpers.expand(curve) 748 colMirror, rowMirror = helpers.expand(mirror) 749 colFlip, rowFlip = helpers.expand(flip) 750 colMinSize, rowMinSize = helpers.expand(minSize) 751 752 containerX, containerY, containerW, containerH = layout.toCoords(container) 753 754 # Distribute columns and rows independently. 755 colBoxes = distributeCoords( 756 container=container, 757 create="columns", 758 steps=colSteps, 759 gap=colGap, 760 curve=colCurve, 761 curveStrength=curveStrength, 762 mirror=colMirror, 763 flip=colFlip, 764 ) 765 766 rowBoxes = distributeCoords( 767 container=container, 768 create="rows", 769 steps=rowSteps, 770 gap=rowGap, 771 curve=rowCurve, 772 curveStrength=curveStrength, 773 mirror=rowMirror, 774 flip=rowFlip, 775 ) 776 777 def _parseAxisMin(value, base): 778 if value is None: 779 return 0 780 isUnsignedPct = ( 781 isinstance(value, str) 782 and value.endswith("%") 783 and not value[:-1].startswith(("+", "-")) 784 ) 785 return ( 786 layout.parseUnit(value, base=base) 787 if isUnsignedPct 788 else layout.parseUnit(value) 789 ) 790 791 colMinWidth = _parseAxisMin(colMinSize, containerW) 792 rowMinHeight = _parseAxisMin(rowMinSize, containerH) 793 colGapValue = layout.parseUnit(colGap) 794 rowGapValue = layout.parseUnit(rowGap) 795 796 # Filter columns below minWidth, then rescale survivors to fill container width. 797 # Gaps between survivors are preserved; only cell widths are scaled. 798 filteredCols = [(x, y, w, h) for x, y, w, h in colBoxes if w >= colMinWidth] 799 if filteredCols and len(filteredCols) < len(colBoxes): 800 totalGapW = (len(filteredCols) - 1) * colGapValue 801 availableW = containerW - totalGapW 802 totalSurvivorW = sum(w for _, _, w, _ in filteredCols) 803 scaleW = availableW / totalSurvivorW if totalSurvivorW else 1 804 rescaled = [] 805 x = containerX 806 for _, _, w, _ in filteredCols: 807 newW = w * scaleW 808 rescaled.append((x, containerY, newW, containerH)) 809 x += newW + colGapValue 810 filteredCols = rescaled 811 812 # Filter rows below minHeight, then rescale survivors to fill container height. 813 # Rows run top-to-bottom (y decreases), gaps are preserved. 814 filteredRows = [(x, y, w, h) for x, y, w, h in rowBoxes if h >= rowMinHeight] 815 if filteredRows and len(filteredRows) < len(rowBoxes): 816 totalGapH = (len(filteredRows) - 1) * rowGapValue 817 availableH = containerH - totalGapH 818 totalSurvivorH = sum(h for _, _, _, h in filteredRows) 819 scaleH = availableH / totalSurvivorH if totalSurvivorH else 1 820 rescaled = [] 821 y = containerY + containerH # start from top edge 822 for _, _, _, h in filteredRows: 823 newH = h * scaleH 824 y -= newH 825 rescaled.append((containerX, y, containerW, newH)) 826 y -= rowGapValue 827 filteredRows = rescaled 828 829 # Combine into full grid: Cartesian product of filtered columns and rows. 830 gridCells = [] 831 for rowX, rowY, rowW, rowH in filteredRows: 832 for colX, colY, colW, colH in filteredCols: 833 gridCells.append((colX, rowY, colW, rowH)) 834 835 return gridCells
175def lerp( 176 start: float | tuple[float, float], stop: float | tuple[float, float], amount: float 177) -> float | tuple[float, float]: 178 """ 179 Linearly interpolates between start and stop by amount. 180 181 Args: 182 start: Start value or tuple. 183 stop: Stop value or tuple. 184 amount: Interpolation factor between 0 and 1. 185 186 Returns: 187 Interpolated value or tuple. 188 189 Example: 190 - `start=0 stop=10 amount=0.5` => 5 191 - `start=(0,0) stop=(10,20) amount=0.5` => (5, 10) 192 """ 193 194 def calc(start, stop): 195 try: 196 return start * (1 - amount) + stop * amount 197 except Exception as e: 198 logger.warning("Lerp failed {} {} {}: {}", start, stop, amount, e) 199 return 0 200 201 if all(isinstance(e, tuple) for e in [start, stop]): 202 return tuple(map(calc, start, stop)) 203 else: 204 return calc(start, stop)
Linearly interpolates between start and stop by amount.
Arguments:
- start: Start value or tuple.
- stop: Stop value or tuple.
- amount: Interpolation factor between 0 and 1.
Returns:
Interpolated value or tuple.
Example:
start=0 stop=10 amount=0.5=> 5start=(0,0) stop=(10,20) amount=0.5=> (5, 10)
207def invLerp(start, stop, amount): 208 """ 209 Calculates the normalized value of amount between start and stop. 210 211 Args: 212 start: Start value. 213 stop: Stop value. 214 amount: Value to normalize. 215 216 Returns: 217 Normalized value between 0 and 1. 218 """ 219 return clamp((amount - start) / (stop - start))
Calculates the normalized value of amount between start and stop.
Arguments:
- start: Start value.
- stop: Stop value.
- amount: Value to normalize.
Returns:
Normalized value between 0 and 1.
222def clamp(amount, aMin=0, aMax=1): 223 """Returns amount clamped between aMin and aMax.""" 224 return min(aMax, max(aMin, amount))
Returns amount clamped between aMin and aMax.
227def lerpRange(start1, stop1, start2, stop2, amount): 228 """ 229 Maps amount from range [start1, stop1] to [start2, stop2] using linear interpolation. 230 231 Args: 232 start1: Start of input range. 233 stop1: End of input range. 234 start2: Start of output range. 235 stop2: End of output range. 236 amount: Value in input range. 237 238 Returns: 239 Value mapped to output range. 240 """ 241 return lerp(start2, stop2, invLerp(start1, stop1, amount))
Maps amount from range [start1, stop1] to [start2, stop2] using linear interpolation.
Arguments:
- start1: Start of input range.
- stop1: End of input range.
- start2: Start of output range.
- stop2: End of output range.
- amount: Value in input range.
Returns:
Value mapped to output range.
244def retrieve( 245 curve: Literal["sine", "quad", "cubic", "quart", "quint"], 246 direction: Literal["in", "out"], 247) -> Callable[[float], float]: 248 """ 249 Get easing function on the fly. 250 251 Args: 252 curve: Easing function type. 253 direction: 'in' or 'out'. 254 255 Returns: 256 Corresponding easing function. 257 """ 258 functions = dict( 259 sine=(easeInSine, easeOutSine), 260 quad=(easeInQuad, easeOutQuad), 261 cubic=(easeInCubic, easeOutCubic), 262 quart=(easeInQuart, easeOutQuart), 263 quint=(easeInQuint, easeOutQuint), 264 ) 265 266 funcIn, funcOut = functions.get(curve) 267 return funcIn if direction == "in" else funcOut
Get easing function on the fly.
Arguments:
- curve: Easing function type.
- direction: 'in' or 'out'.
Returns:
Corresponding easing function.
285def interpolate( 286 start: float | tuple[float, ...] = 0, 287 stop: float | tuple[float, ...] = 10, 288 steps: int = 10, 289 offset: float = 0, 290 speed: float = 1, 291 curve: Callable[[float], float] = linear, 292 curveStrength: float = 1, 293 mirror: bool = False, 294 flip: bool = False, 295) -> list[float | tuple[float, ...]]: 296 """ 297 Create n steps with easing. 298 299 Args: 300 start: Start value. 301 stop: Stop value. 302 steps: Number of steps. 303 offset: Time offset. 304 speed: Number of iterations. 305 curve: Easing function to use. 306 curveStrength: Amount of easing applied (0 = None, i.e. `linear`). 307 mirror: If True, mirror the easing. 308 flip: If True, flip start and stop. 309 310 Returns: 311 List of interpolated values. 312 313 Example: 314 - Create n steps with easing => `[0, 0.11, 0.3, 0.7, 1]` 315 - Can be flipped/mirrored: `[0, 0.6, 1, 0.6, 0]` 316 """ 317 318 def _doStep(step: int) -> float | tuple[float, ...]: 319 prog = (step / end) * speed 320 321 if prog % 1: 322 prog = prog % 1 323 else: 324 prog = min(prog, 1) 325 326 if mirror: 327 isSecondHalf = prog > 0.5 328 if isSecondHalf: 329 prog = (end - step) / minorHalf 330 else: 331 prog = step / minorHalf 332 333 progOffset = prog + offset 334 335 if progOffset > 1: 336 progOffset -= 1 337 338 progEase = curve(progOffset) 339 # 0 = linear, 1 = 100% eased 340 progFactored = lerp(progOffset, progEase, abs(curveStrength)) 341 342 value = lerp(start, stop, progFactored) 343 return value 344 345 def _doStepExactCycles(step: int, cycleSteps: int) -> float | tuple[float, ...]: 346 # Include both cycle endpoints, so each cycle reaches min->max exactly. 347 # Example: steps=20, speed=2 => idx 9 == max, idx 10 == min. 348 cycleStep = step % cycleSteps 349 cycleDenominator = max(cycleSteps - 1, 1) 350 prog = cycleStep / cycleDenominator 351 352 if mirror: 353 isSecondHalf = prog > 0.5 354 if isSecondHalf: 355 prog = 1 - prog 356 357 progOffset = prog + offset 358 359 if progOffset > 1: 360 progOffset -= 1 361 362 progEase = curve(progOffset) 363 # 0 = linear, 1 = 100% eased 364 progFactored = lerp(progOffset, progEase, abs(curveStrength)) 365 366 value = lerp(start, stop, progFactored) 367 return value 368 369 if flip: 370 start, stop = stop, start 371 372 end = steps - 1 373 # 5 steps => 2 374 minorHalf = ceil(steps / 2) - 1 or 1 375 376 cycleInfo = _analyzeEvenCycles(steps, speed) 377 if cycleInfo: 378 _, cycleSteps = cycleInfo 379 return [_doStepExactCycles(step, cycleSteps) for step in range(steps)] 380 381 return [_doStep(step) for step in range(steps)]
Create n steps with easing.
Arguments:
- start: Start value.
- stop: Stop value.
- steps: Number of steps.
- offset: Time offset.
- speed: Number of iterations.
- curve: Easing function to use.
- curveStrength: Amount of easing applied (0 = None, i.e.
linear). - mirror: If True, mirror the easing.
- flip: If True, flip start and stop.
Returns:
List of interpolated values.
Example:
- Create n steps with easing =>
[0, 0.11, 0.3, 0.7, 1]- Can be flipped/mirrored:
[0, 0.6, 1, 0.6, 0]
384def distributeValue( 385 value: float, 386 steps: int = 10, 387 gap: layout.UnitSource = 0, 388 speed: float = 1, 389 curve=linear, 390 curveStrength: float = 1, 391 mirror: bool = False, 392 flip: bool = False, 393 output: Literal["segments", "bounds"] = "segments", 394) -> list[float]: 395 """Distributes a total value based on easing curves. 396 397 Args: 398 value: Total value to distribute (e.g., width). 399 steps: Number of steps to create. 400 gap: Gap between steps (can be a unit string like '10pt'). 401 speed: Number of interpolation iterations (same behavior as interpolate). 402 curve: Easing function to determine distribution. 403 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve). 404 mirror: If True, mirror the easing curve. 405 flip: If True, flip the easing curve. 406 output: Return segment sizes ("segments") or edge-aligned positions 407 from lower to upper bound ("bounds"). 408 409 Returns: 410 List of segment sizes ("segments") or positions from 0..value 411 including both bounds ("bounds"). 412 413 Example: 414 - Distribute 800 into 3 segments with easing => [500, 200, 100] 415 - With gaps of 10pt => [490, 190, 90] (total 780 + 20 gap = 800) 416 """ 417 if steps <= 0: 418 return [] 419 420 gapCount = max(steps - 1, 0) 421 gapSum = layout.parseUnit(gap) * gapCount 422 availValue = value - gapSum 423 424 # Return empty list if no space is available for segments. 425 if availValue <= 0: 426 return [] 427 428 if steps == 1: 429 if output == "bounds": 430 return [0.0] 431 return [availValue] 432 433 cycleInfo = _analyzeEvenCycles(steps, speed) 434 if cycleInfo: 435 # For exact cycle splits (e.g. steps=8, speed=2), build one cycle and repeat 436 # so each cycle contributes identical segment weights without a boundary seam. 437 cycleCount, cycleSteps = cycleInfo 438 cycleBoundaries = interpolate( 439 start=0, 440 stop=1, 441 steps=cycleSteps + 1, 442 speed=1, 443 curve=curve, 444 mirror=mirror, 445 flip=False, 446 ) 447 cycleWeights = [ 448 abs(stop - start) 449 for start, stop in zip(cycleBoundaries, cycleBoundaries[1:]) 450 ] 451 easedWeights = cycleWeights * cycleCount 452 else: 453 # Build segment weights from boundary intervals so the first segment 454 # starts at the minimum bound (important for linear correctness). 455 boundaries = interpolate( 456 start=0, 457 stop=1, 458 steps=steps + 1, 459 speed=speed, 460 curve=curve, 461 mirror=mirror, 462 flip=False, 463 ) 464 465 # Use interval lengths between consecutive boundaries as raw weights. 466 easedWeights = [ 467 abs(stop - start) for start, stop in zip(boundaries, boundaries[1:]) 468 ] 469 470 if flip: 471 easedWeights.reverse() 472 473 # Avoid zero-width segments when the first eased sample is exactly 0. 474 easedWeights = [weight if weight > 0 else 1e-9 for weight in easedWeights] 475 easedTotal = sum(easedWeights) 476 477 # Degenerate curves (all equal) fall back to equal distribution. 478 if easedTotal == 0: 479 easedWeights = [1.0] * steps 480 easedTotal = float(steps) 481 482 easedNorm = [weight / easedTotal for weight in easedWeights] 483 linearNorm = [1 / steps] * steps 484 485 strength = clamp(curveStrength, 0, 1) 486 normWeights = [ 487 lerp(linear, eased, strength) for linear, eased in zip(linearNorm, easedNorm) 488 ] 489 490 segments = [availValue * weight for weight in normWeights] 491 492 # Keep an exact total despite floating-point rounding. 493 segments[-1] = availValue - sum(segments[:-1]) 494 495 if output == "segments": 496 return segments 497 498 # Bounds mode returns `steps` positions from lower to upper bound. 499 easedBounds = interpolate( 500 start=0, 501 stop=value, 502 steps=steps, 503 speed=speed, 504 curve=curve, 505 mirror=mirror, 506 flip=flip, 507 ) 508 509 linearBounds = interpolate( 510 start=0, 511 stop=value, 512 steps=steps, 513 speed=speed, 514 curve=linear, 515 ) 516 517 bounds = [ 518 lerp(linearValue, easedValue, strength) 519 for linearValue, easedValue in zip(linearBounds, easedBounds) 520 ] 521 522 # Keep exact lower/upper endpoints despite floating-point rounding. 523 bounds[0] = 0.0 524 bounds[-1] = value 525 return bounds
Distributes a total value based on easing curves.
Arguments:
- value: Total value to distribute (e.g., width).
- steps: Number of steps to create.
- gap: Gap between steps (can be a unit string like '10pt').
- speed: Number of interpolation iterations (same behavior as interpolate).
- curve: Easing function to determine distribution.
- curveStrength: Strength of the easing effect (0 = linear, 1 = full curve).
- mirror: If True, mirror the easing curve.
- flip: If True, flip the easing curve.
- output: Return segment sizes ("segments") or edge-aligned positions from lower to upper bound ("bounds").
Returns:
List of segment sizes ("segments") or positions from 0..value including both bounds ("bounds").
Example:
- Distribute 800 into 3 segments with easing => [500, 200, 100]
- With gaps of 10pt => [490, 190, 90] (total 780 + 20 gap = 800)
528def distributeCoords( 529 container: layout.Coordinates, 530 create: layout.LayoutFlow, 531 steps: int = 10, 532 gap: layout.UnitSource = 0, 533 speed: float = 1, 534 curve=linear, 535 curveStrength: float = 1, 536 mirror: bool = False, 537 flip: bool = False, 538 minSize: layout.UnitSource = 0, 539) -> list[layout.Coordinates]: 540 """Distributes coordinates within a container based on easing curves. 541 542 Args: 543 container: Tuple of (x, y, width, height) defining the container. 544 create: "rows" or "columns" to specify distribution mode. 545 steps: Number of rows or columns to create. 546 gap: Gap between rows/columns (can be a unit string like '10pt'). 547 speed: Number of interpolation iterations (same behavior as interpolate). 548 curve: Easing function to determine distribution. 549 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve 550 mirror: If True, mirror the easing curve. 551 flip: If True, flip the easing curve. 552 minSize: Minimum segment size to keep on the distribution axis. 553 In ``create="columns"`` this is a minimum width; in 554 ``create="rows"`` this is a minimum height. Segments below 555 the threshold are removed and survivors are rescaled to fill 556 the container while preserving gaps. Use None to disable filtering. 557 Unsigned % uses the main axis dimension as base. 558 559 Returns: 560 List of coordinates (x, y, width, height) for each distributed row/column. 561 """ 562 containerX, containerY, containerW, containerH = layout.toCoords(container) 563 isCols = create == "columns" 564 isRows = not isCols 565 modeValue = containerW if isCols else containerH 566 567 segments = distributeValue( 568 value=modeValue, 569 steps=steps, 570 gap=gap, 571 speed=speed, 572 curve=curve, 573 curveStrength=curveStrength, 574 mirror=mirror, 575 flip=flip, 576 ) 577 578 output = [] 579 gapValue = layout.parseUnit(gap) 580 581 isUnsignedPct = ( 582 isinstance(minSize, str) 583 and minSize.endswith("%") 584 and not minSize[:-1].startswith(("+", "-")) 585 ) 586 minSizeValue = ( 587 0 588 if minSize is None 589 else ( 590 layout.parseUnit(minSize, base=modeValue) 591 if isUnsignedPct 592 else layout.parseUnit(minSize) 593 ) 594 ) 595 596 if minSizeValue > 0: 597 filteredSegments = [seg for seg in segments if seg >= minSizeValue] 598 if not filteredSegments: 599 return [] 600 if len(filteredSegments) < len(segments): 601 totalGap = (len(filteredSegments) - 1) * gapValue 602 availableValue = modeValue - totalGap 603 totalSurvivor = sum(filteredSegments) 604 scale = availableValue / totalSurvivor if totalSurvivor else 1 605 segments = [seg * scale for seg in filteredSegments] 606 else: 607 segments = filteredSegments 608 609 itemX = containerX 610 # Start from top for rows 611 itemY = containerY + containerH if isRows else containerY 612 613 for seg in segments: 614 itemW = seg if isCols else containerW 615 itemH = seg if isRows else containerH 616 617 if isRows: 618 itemY -= seg 619 620 easedCoords = itemX, itemY, itemW, itemH 621 output.append(easedCoords) 622 623 if isCols: 624 itemX += itemW + gapValue 625 else: 626 itemY -= gapValue 627 628 return output
Distributes coordinates within a container based on easing curves.
Arguments:
- container: Tuple of (x, y, width, height) defining the container.
- create: "rows" or "columns" to specify distribution mode.
- steps: Number of rows or columns to create.
- gap: Gap between rows/columns (can be a unit string like '10pt').
- speed: Number of interpolation iterations (same behavior as interpolate).
- curve: Easing function to determine distribution.
- curveStrength: Strength of the easing effect (0 = linear, 1 = full curve
- mirror: If True, mirror the easing curve.
- flip: If True, flip the easing curve.
- minSize: Minimum segment size to keep on the distribution axis.
In
create="columns"this is a minimum width; increate="rows"this is a minimum height. Segments below the threshold are removed and survivors are rescaled to fill the container while preserving gaps. Use None to disable filtering. Unsigned % uses the main axis dimension as base.
Returns:
List of coordinates (x, y, width, height) for each distributed row/column.
631def distributeCoords2D( 632 container: layout.Coordinates, 633 create: layout.LayoutFlow, 634 steps: int = 10, 635 gap: layout.UnitSource = 0, 636 curve=linear, 637 curveStrength: float = 0.5, 638 crossStart: float = 1, 639 crossStop: float = 0.5, 640 crossAlign: tuple[layout.AlignX, layout.AlignY] = ("left", "top"), 641 mirror: bool = False, 642 flip: bool = False, 643 minSize: layout.UnitSource = 0, 644) -> list[layout.Coordinates]: 645 """Distributes coordinates in two dimensions within a container based on easing curves. 646 647 Args: 648 container: Tuple of (x, y, width, height) defining the container. 649 create: "rows" or "columns" to specify distribution mode. 650 steps: Number of rows or columns to create. 651 gap: Gap between rows/columns (can be a unit string like '10pt'). 652 curve: Easing function to determine distribution. 653 curveStrength: Strength of the easing effect (0 = linear, 1 = full curve). 654 crossStart: Start value for the cross axis interpolation (0 to 1). 655 crossStop: Stop value for the cross axis interpolation (0 to 1). 656 crossAlign: Alignment of the child boxes on the cross axis. 657 mirror: If True, mirror the easing curve. 658 flip: If True, flip the easing curve. 659 minSize: Minimum segment size on the main distribution axis. 660 In ``create="columns"`` this is a minimum width; in 661 ``create="rows"`` this is a minimum height. Uses the same 662 behavior as distributeCoords. 663 664 Returns: 665 List of coordinates (x, y, width, height) for each distributed box. 666 """ 667 containerW, containerH = layout.toDimensions(container) 668 isCols = create == "columns" 669 670 mainCurve, crossCurve = helpers.expand(curve) 671 672 boxes = distributeCoords( 673 container=container, 674 create=create, 675 steps=steps, 676 gap=gap, 677 curve=mainCurve, 678 curveStrength=curveStrength, 679 mirror=mirror, 680 flip=flip, 681 minSize=minSize, 682 ) 683 684 # Cross axis is perpendicular to distribution axis. 685 targetValue = containerH if isCols else containerW 686 values = interpolate( 687 start=targetValue * crossStart, 688 stop=targetValue * crossStop, 689 steps=len(boxes), 690 curve=crossCurve, 691 mirror=mirror, 692 flip=flip, 693 ) 694 695 # Combine 696 output = [] 697 for [parent, length] in zip(boxes, values): 698 childX, childY, childW, childH = parent 699 700 if isCols: 701 childH = length 702 else: 703 childW = length 704 705 child = childX, childY, childW, childH 706 707 output.append(layout.align(parent=parent, child=child, position=crossAlign)) 708 709 return output
Distributes coordinates in two dimensions within a container based on easing curves.
Arguments:
- container: Tuple of (x, y, width, height) defining the container.
- create: "rows" or "columns" to specify distribution mode.
- steps: Number of rows or columns to create.
- gap: Gap between rows/columns (can be a unit string like '10pt').
- curve: Easing function to determine distribution.
- curveStrength: Strength of the easing effect (0 = linear, 1 = full curve).
- crossStart: Start value for the cross axis interpolation (0 to 1).
- crossStop: Stop value for the cross axis interpolation (0 to 1).
- crossAlign: Alignment of the child boxes on the cross axis.
- mirror: If True, mirror the easing curve.
- flip: If True, flip the easing curve.
- minSize: Minimum segment size on the main distribution axis.
In
create="columns"this is a minimum width; increate="rows"this is a minimum height. Uses the same behavior as distributeCoords.
Returns:
List of coordinates (x, y, width, height) for each distributed box.
712def distributeCoordsGrid( 713 container: layout.Coordinates, 714 steps: int | tuple[int, int] = 5, 715 gap: layout.UnitSource | tuple[layout.UnitSource, layout.UnitSource] = 0, 716 curve=linear, 717 curveStrength: float = 1, 718 mirror: bool | tuple[bool, bool] = False, 719 flip: bool | tuple[bool, bool] = False, 720 minSize: layout.UnitSource | tuple[layout.UnitSource, layout.UnitSource] = 0, 721) -> list[layout.Coordinates]: 722 """Generate a grid of coordinates by independently distributing rows and columns. 723 724 Per-axis overrides use tuple unpacking: (column_value, row_value). 725 Single values apply to both axes. 726 727 Args: 728 container: Tuple of (x, y, width, height) defining the container. 729 steps: Number of cells per axis, or (colSteps, rowSteps). 730 gap: Gap between cells, or (colGap, rowGap). 731 curve: Easing function(s) for distribution. 732 curveStrength: Easing strength (0 = linear, 1 = full curve). 733 mirror: Mirror distribution, or (colMirror, rowMirror). 734 flip: Flip distribution, or (colFlip, rowFlip). 735 minSize: Minimum cell size to include, or (minWidth, minHeight) per axis. 736 Columns narrower than minWidth and rows shorter than minHeight are removed; 737 survivors are rescaled to fill the container. Gaps are not scaled. 738 Use None for either axis to skip filtering on that axis, e.g. (None, '10%') 739 applies only a row height minimum. Unsigned % uses the main axis dimension 740 as base (width for columns, height for rows). 741 742 Returns: 743 List of coordinates (x, y, width, height) for each grid cell. 744 """ 745 # Unpack per-axis overrides. 746 colSteps, rowSteps = helpers.expand(steps) 747 colGap, rowGap = helpers.expand(gap) 748 colCurve, rowCurve = helpers.expand(curve) 749 colMirror, rowMirror = helpers.expand(mirror) 750 colFlip, rowFlip = helpers.expand(flip) 751 colMinSize, rowMinSize = helpers.expand(minSize) 752 753 containerX, containerY, containerW, containerH = layout.toCoords(container) 754 755 # Distribute columns and rows independently. 756 colBoxes = distributeCoords( 757 container=container, 758 create="columns", 759 steps=colSteps, 760 gap=colGap, 761 curve=colCurve, 762 curveStrength=curveStrength, 763 mirror=colMirror, 764 flip=colFlip, 765 ) 766 767 rowBoxes = distributeCoords( 768 container=container, 769 create="rows", 770 steps=rowSteps, 771 gap=rowGap, 772 curve=rowCurve, 773 curveStrength=curveStrength, 774 mirror=rowMirror, 775 flip=rowFlip, 776 ) 777 778 def _parseAxisMin(value, base): 779 if value is None: 780 return 0 781 isUnsignedPct = ( 782 isinstance(value, str) 783 and value.endswith("%") 784 and not value[:-1].startswith(("+", "-")) 785 ) 786 return ( 787 layout.parseUnit(value, base=base) 788 if isUnsignedPct 789 else layout.parseUnit(value) 790 ) 791 792 colMinWidth = _parseAxisMin(colMinSize, containerW) 793 rowMinHeight = _parseAxisMin(rowMinSize, containerH) 794 colGapValue = layout.parseUnit(colGap) 795 rowGapValue = layout.parseUnit(rowGap) 796 797 # Filter columns below minWidth, then rescale survivors to fill container width. 798 # Gaps between survivors are preserved; only cell widths are scaled. 799 filteredCols = [(x, y, w, h) for x, y, w, h in colBoxes if w >= colMinWidth] 800 if filteredCols and len(filteredCols) < len(colBoxes): 801 totalGapW = (len(filteredCols) - 1) * colGapValue 802 availableW = containerW - totalGapW 803 totalSurvivorW = sum(w for _, _, w, _ in filteredCols) 804 scaleW = availableW / totalSurvivorW if totalSurvivorW else 1 805 rescaled = [] 806 x = containerX 807 for _, _, w, _ in filteredCols: 808 newW = w * scaleW 809 rescaled.append((x, containerY, newW, containerH)) 810 x += newW + colGapValue 811 filteredCols = rescaled 812 813 # Filter rows below minHeight, then rescale survivors to fill container height. 814 # Rows run top-to-bottom (y decreases), gaps are preserved. 815 filteredRows = [(x, y, w, h) for x, y, w, h in rowBoxes if h >= rowMinHeight] 816 if filteredRows and len(filteredRows) < len(rowBoxes): 817 totalGapH = (len(filteredRows) - 1) * rowGapValue 818 availableH = containerH - totalGapH 819 totalSurvivorH = sum(h for _, _, _, h in filteredRows) 820 scaleH = availableH / totalSurvivorH if totalSurvivorH else 1 821 rescaled = [] 822 y = containerY + containerH # start from top edge 823 for _, _, _, h in filteredRows: 824 newH = h * scaleH 825 y -= newH 826 rescaled.append((containerX, y, containerW, newH)) 827 y -= rowGapValue 828 filteredRows = rescaled 829 830 # Combine into full grid: Cartesian product of filtered columns and rows. 831 gridCells = [] 832 for rowX, rowY, rowW, rowH in filteredRows: 833 for colX, colY, colW, colH in filteredCols: 834 gridCells.append((colX, rowY, colW, rowH)) 835 836 return gridCells
Generate a grid of coordinates by independently distributing rows and columns.
Per-axis overrides use tuple unpacking: (column_value, row_value). Single values apply to both axes.
Arguments:
- container: Tuple of (x, y, width, height) defining the container.
- steps: Number of cells per axis, or (colSteps, rowSteps).
- gap: Gap between cells, or (colGap, rowGap).
- curve: Easing function(s) for distribution.
- curveStrength: Easing strength (0 = linear, 1 = full curve).
- mirror: Mirror distribution, or (colMirror, rowMirror).
- flip: Flip distribution, or (colFlip, rowFlip).
- minSize: Minimum cell size to include, or (minWidth, minHeight) per axis. Columns narrower than minWidth and rows shorter than minHeight are removed; survivors are rescaled to fill the container. Gaps are not scaled. Use None for either axis to skip filtering on that axis, e.g. (None, '10%') applies only a row height minimum. Unsigned % uses the main axis dimension as base (width for columns, height for rows).
Returns:
List of coordinates (x, y, width, height) for each grid cell.