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
def linear(x: float) -> float:
17def linear(x: float) -> float:
18    return x
def easeInQuad(x: float) -> float:
21def easeInQuad(x: float) -> float:
22    return x * x
def easeOutQuad(x: float) -> float:
25def easeOutQuad(x: float) -> float:
26    return 1 - (1 - x) * (1 - x)
def easeInOutQuad(x: float) -> float:
29def easeInOutQuad(x: float) -> float:
30    return 2 * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 2) / 2
def easeInCubic(x: float) -> float:
33def easeInCubic(x: float) -> float:
34    return x * x * x
def easeOutCubic(x: float) -> float:
37def easeOutCubic(x: float) -> float:
38    return 1 - pow(1 - x, 3)
def easeInOutCubic(x: float) -> float:
41def easeInOutCubic(x: float) -> float:
42    return 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2
def easeInQuart(x: float) -> float:
45def easeInQuart(x: float) -> float:
46    return x * x * x * x
def easeOutQuart(x: float) -> float:
49def easeOutQuart(x: float) -> float:
50    return 1 - pow(1 - x, 4)
def easeInOutQuart(x: float) -> float:
53def easeInOutQuart(x: float) -> float:
54    return 8 * x * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 4) / 2
def easeInQuint(x: float) -> float:
57def easeInQuint(x: float) -> float:
58    return x * x * x * x * x
def easeOutQuint(x: float) -> float:
61def easeOutQuint(x: float) -> float:
62    return 1 - pow(1 - x, 5)
def easeInOutQuint(x: float) -> float:
65def easeInOutQuint(x: float) -> float:
66    return 16 * x * x * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 5) / 2
def easeInSine(x: float) -> float:
69def easeInSine(x: float) -> float:
70    return 1 - cos((x * pi) / 2)
def easeOutSine(x: float) -> float:
73def easeOutSine(x: float) -> float:
74    return sin((x * pi) / 2)
def easeInOutSine(x: float) -> float:
77def easeInOutSine(x: float) -> float:
78    return -(cos(pi * x) - 1) / 2
def easeInExpo(x: float) -> float:
81def easeInExpo(x: float) -> float:
82    return 0 if x == 0 else pow(2, 10 * x - 10)
def easeOutExpo(x: float) -> float:
85def easeOutExpo(x: float) -> float:
86    return 1 if x == 1 else 1 - pow(2, -10 * x)
def easeInOutExpo(x: float) -> float:
 89def easeInOutExpo(x: float) -> float:
 90    return (
 91        0
 92        if x == 0
 93        else (
 94            1
 95            if x == 1
 96            else pow(2, 20 * x - 10) / 2
 97            if x < 0.5
 98            else (2 - pow(2, -20 * x + 10))
 99        )
100    )
def easeInCirc(x: float) -> float:
103def easeInCirc(x: float) -> float:
104    return 1 - sqrt(1 - pow(x, 2))
def easeOutCirc(x: float) -> float:
107def easeOutCirc(x: float) -> float:
108    return sqrt(1 - pow(x - 1, 2))
def easeInOutCirc(x: float) -> float:
111def easeInOutCirc(x: float) -> float:
112    return (
113        (1 - sqrt(1 - pow(2 * x, 2))) / 2
114        if x < 0.5
115        else (sqrt(1 - pow(-2 * x + 2, 2)) + 1) / 2
116    )
def easeInBack(x: float) -> float:
119def easeInBack(x: float) -> float:
120    return _c3 * pow(x, 3) - _c1 * pow(x, 2)
def easeOutBack(x: float) -> float:
123def easeOutBack(x: float) -> float:
124    return 1 + _c3 * pow(x - 1, 3) + _c1 * pow(x - 1, 2)
def easeInOutBack(x: float) -> float:
127def easeInOutBack(x: float) -> float:
128    return (
129        (pow(2 * x, 2) * ((_c2 + 1) * 2 * x - _c2)) / 2
130        if x < 0.5
131        else (pow(2 * x - 2, 2) * ((_c2 + 1) * (x * 2 - 2) + _c2) + 2) / 2
132    )
def easeInElastic(x: float) -> float:
135def easeInElastic(x: float) -> float:
136    return (
137        0
138        if x == 0
139        else 1
140        if x == 1
141        else -pow(2, 10 * x - 10) * sin((x * 10 - 10.75) * _c4)
142    )
def easeOutElastic(x: float) -> float:
145def easeOutElastic(x: float) -> float:
146    return (
147        0
148        if x == 0
149        else 1
150        if x == 1
151        else pow(2, -10 * x) * sin((x * 10 - 0.75) * _c4) + 1
152    )
def easeInOutElastic(x: float) -> float:
155def easeInOutElastic(x: float) -> float:
156    return (
157        0
158        if x == 0
159        else (
160            1
161            if x == 1
162            else (
163                -(pow(2, 20 * x - 10) * sin((20 * x - 11.125) * _c5)) / 2
164                if x < 0.5
165                else (pow(2, -20 * x + 10) * sin((20 * x - 11.125) * _c5)) / 2 + 1
166            )
167        )
168    )
def lerp( start: float | tuple[float, float], stop: float | tuple[float, float], amount: float) -> float | tuple[float, float]:
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 => 5
  • start=(0,0) stop=(10,20) amount=0.5 => (5, 10)
def invLerp(start, stop, amount):
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.

def clamp(amount, aMin=0, aMax=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.

def lerpRange(start1, stop1, start2, stop2, amount):
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.

def retrieve( curve: Literal['sine', 'quad', 'cubic', 'quart', 'quint'], direction: Literal['in', 'out']) -> Callable[[float], float]:
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.

def interpolate( start: float | tuple[float, ...] = 0, stop: float | tuple[float, ...] = 10, steps: int = 10, offset: float = 0, speed: float = 1, curve: Callable[[float], float] = <function linear>, curveStrength: float = 1, mirror: bool = False, flip: bool = False) -> list[float | tuple[float, ...]]:
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]
def distributeValue( value: float, steps: int = 10, gap: int | float | str | None = 0, speed: float = 1, curve=<function linear>, curveStrength: float = 1, mirror: bool = False, flip: bool = False, output: Literal['segments', 'bounds'] = 'segments') -> list[float]:
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)
def distributeCoords( container: tuple[float, float, float, float], create: Literal['rows', 'columns'], steps: int = 10, gap: int | float | str | None = 0, speed: float = 1, curve=<function linear>, curveStrength: float = 1, mirror: bool = False, flip: bool = False, minSize: int | float | str | None = 0) -> list[tuple[float, float, float, float]]:
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; in create="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.

def distributeCoords2D( container: tuple[float, float, float, float], create: Literal['rows', 'columns'], steps: int = 10, gap: int | float | str | None = 0, curve=<function linear>, curveStrength: float = 0.5, crossStart: float = 1, crossStop: float = 0.5, crossAlign: tuple[typing.Literal['left', 'center', 'right', 'stretch', None], typing.Literal['top', 'center', 'bottom', 'stretch', None]] = ('left', 'top'), mirror: bool = False, flip: bool = False, minSize: int | float | str | None = 0) -> list[tuple[float, float, float, float]]:
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; in create="rows" this is a minimum height. Uses the same behavior as distributeCoords.
Returns:

List of coordinates (x, y, width, height) for each distributed box.

def distributeCoordsGrid( container: tuple[float, float, float, float], steps: int | tuple[int, int] = 5, gap: int | float | str | None | tuple[int | float | str | None, int | float | str | None] = 0, curve=<function linear>, curveStrength: float = 1, mirror: bool | tuple[bool, bool] = False, flip: bool | tuple[bool, bool] = False, minSize: int | float | str | None | tuple[int | float | str | None, int | float | str | None] = 0) -> list[tuple[float, float, float, float]]:
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.