| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Package layout provides generic spatial management primitives and algorithms |
| // for 1D and 2D layouts. It is independent of any specific UI framework. |
| package layout |
| |
| const ( |
| // unbounded is a sentinel value for Constraint.Max indicating that |
| // a component can expand to fill any available space. |
| unbounded = -1 |
| ) |
| |
| // Unbounded returns the sentinel value for an unbounded layout dimension. |
| func Unbounded() int { |
| return unbounded |
| } |
| |
| // BoundedSize resolves a layout dimension into a concrete, non-negative |
| // integer footprint. Sentinel values like unbounded are resolved to 0. |
| func BoundedSize(s int) int { |
| return ResolveSize(s, 0) |
| } |
| |
| // IsUnbounded returns true if the assigned size is unbounded. |
| func IsUnbounded(assigned int) bool { |
| return assigned == unbounded |
| } |
| |
| // ResolveSize resolves an assigned layout dimension into a footprint. |
| // If the assigned size is unbounded, it returns the provided intrinsic size. |
| func ResolveSize(assigned, intrinsic int) int { |
| if assigned == unbounded { |
| return intrinsic |
| } |
| if assigned < 0 { |
| return 0 |
| } |
| return assigned |
| } |
| |
| // Constraint represents the bounds and weight for a single dimension. |
| type Constraint struct { |
| // Min is the minimum required space for this element. |
| Min int |
| |
| // Max is the maximum allowed space for this element. |
| // Use Unbounded() to indicate no upper bound. |
| Max int |
| |
| // Weight is a "soft" constraint that determines how extra space is distributed. |
| Weight int |
| } |
| |
| // IsUnbounded returns true if the constraint has no upper bound. |
| func (c Constraint) IsUnbounded() bool { |
| return c.Max == unbounded |
| } |
| |
| // Fixed returns a constraint for an element with a rigid, unchanging size. |
| func Fixed(size int) Constraint { |
| return Constraint{Min: size, Max: size, Weight: 0} |
| } |
| |
| // Expandable returns a constraint for an element that can grow infinitely. |
| func Expandable(min, weight int) Constraint { |
| return Constraint{Min: min, Max: unbounded, Weight: weight} |
| } |
| |
| // Bounded returns a constraint for an element that can grow up to a maximum. |
| func Bounded(min, max, weight int) Constraint { |
| return Constraint{Min: min, Max: max, Weight: weight} |
| } |
| |
| // Sum combines two constraints into a single aggregate constraint. |
| func (c Constraint) Sum(other Constraint) Constraint { |
| return Constraint{ |
| Min: c.Min + other.Min, |
| Max: AddMax(c.Max, other.Max), |
| Weight: c.Weight + other.Weight, |
| } |
| } |
| |
| // AddMax adds two layout dimensions, correctly handling the Unbounded sentinel. |
| func AddMax(a, b int) int { |
| if IsUnbounded(a) || IsUnbounded(b) { |
| return unbounded |
| } |
| return a + b |
| } |
| |
| // MaxConstraint returns a constraint that satisfies the maximum requirement of |
| // two constraints. This is useful for determining the height of a Row. |
| func MaxConstraint(a, b Constraint) Constraint { |
| maxVal := 0 |
| if a.IsUnbounded() || b.IsUnbounded() { |
| maxVal = unbounded |
| } else { |
| maxVal = max(a.Max, b.Max) |
| } |
| |
| return Constraint{ |
| Min: max(a.Min, b.Min), |
| Max: maxVal, |
| Weight: max(a.Weight, b.Weight), |
| } |
| } |
| |
| // ConstraintSum reduces a slice of constraints into a single aggregate. |
| func ConstraintSum(constraints []Constraint) Constraint { |
| if len(constraints) == 0 { |
| return Constraint{} |
| } |
| res := constraints[0] |
| for i := 1; i < len(constraints); i++ { |
| res = res.Sum(constraints[i]) |
| } |
| return res |
| } |
| |
| // DistributeProportionally divides a total width among multiple elements |
| // based on their natural widths. It structurally guarantees that the |
| // returned widths sum exactly to total. |
| func DistributeProportionally(total int, naturalWidths []int) []int { |
| if len(naturalWidths) == 0 { |
| return nil |
| } |
| constraints := make([]Constraint, len(naturalWidths)) |
| for i, w := range naturalWidths { |
| // Minimum 1 to ensure something is visible, Weight = natural width. |
| constraints[i] = Constraint{Min: 1, Max: unbounded, Weight: w} |
| } |
| return Distribute(total, constraints) |
| } |
| |
| // Distribute allocates a total amount of space among multiple slots while |
| // satisfying their Min and Max constraints. It distributes surplus space |
| // proportionally based on Weight, and ensures all allocated units are |
| // accounted for (redistributing remainder from integer division). |
| func Distribute(total int, constraints []Constraint) []int { |
| count := len(constraints) |
| if count == 0 { |
| return nil |
| } |
| |
| res := make([]int, count) |
| minSum := 0 |
| for i, c := range constraints { |
| res[i] = c.Min |
| minSum += c.Min |
| } |
| |
| // Underflow case: Total space cannot satisfy the sum of minimums. |
| if total <= minSum { |
| remaining := total |
| for i := 0; i < count; i++ { |
| if remaining >= constraints[i].Min { |
| res[i] = constraints[i].Min |
| remaining -= constraints[i].Min |
| } else { |
| res[i] = remaining |
| remaining = 0 |
| } |
| } |
| return res |
| } |
| |
| // Normal case: Total > minSum. We distribute the surplus. |
| surplus := total - minSum |
| isLocked := make([]bool, count) |
| |
| // We iteratively distribute the surplus among flexible elements. |
| // Elements hit their Max and become "locked". |
| for { |
| weightSum := 0 |
| activeCount := 0 |
| for i, c := range constraints { |
| if !isLocked[i] { |
| weightSum += c.Weight |
| activeCount++ |
| } |
| } |
| |
| if activeCount == 0 || surplus <= 0 { |
| break |
| } |
| |
| if weightSum == 0 { |
| // If no weights are provided, ALL remaining elements that can grow |
| // should share the surplus equally. |
| growableCount := 0 |
| for i, c := range constraints { |
| if !isLocked[i] && (c.IsUnbounded() || res[i] < c.Max) { |
| growableCount++ |
| } |
| } |
| |
| if growableCount == 0 { |
| break |
| } |
| |
| share := surplus / growableCount |
| if share == 0 { |
| // Final remainder redistribution will handle this. |
| break |
| } |
| |
| newlyLocked := false |
| for i, c := range constraints { |
| if !isLocked[i] && (c.IsUnbounded() || res[i] < c.Max) { |
| if !c.IsUnbounded() && res[i]+share > c.Max { |
| extra := c.Max - res[i] |
| res[i] = c.Max |
| surplus -= extra |
| isLocked[i] = true |
| newlyLocked = true |
| } |
| } else if !isLocked[i] { |
| // Cannot grow further. |
| isLocked[i] = true |
| newlyLocked = true |
| } |
| } |
| |
| if !newlyLocked { |
| for i, c := range constraints { |
| if !isLocked[i] && (c.IsUnbounded() || res[i] < c.Max) { |
| res[i] += share |
| surplus -= share |
| } |
| } |
| // Pass complete. |
| break |
| } |
| continue |
| } |
| |
| newlyLocked := false |
| activeSurplus := surplus |
| for i, c := range constraints { |
| if isLocked[i] || c.Weight == 0 { |
| continue |
| } |
| |
| share := (c.Weight * activeSurplus) / weightSum |
| if !c.IsUnbounded() && res[i]+share > c.Max { |
| extra := c.Max - res[i] |
| res[i] = c.Max |
| surplus -= extra |
| isLocked[i] = true |
| newlyLocked = true |
| } |
| } |
| |
| if !newlyLocked { |
| // Final allocation for this pass. |
| for i, c := range constraints { |
| if !isLocked[i] && c.Weight > 0 { |
| share := (c.Weight * surplus) / weightSum |
| res[i] += share |
| } |
| } |
| break |
| } |
| } |
| |
| // Account for the remainder (units lost to integer division). |
| currentSum := 0 |
| for _, s := range res { |
| currentSum += s |
| } |
| remaining := total - currentSum |
| |
| // Greedily distribute remaining units to any elements that aren't at Max. |
| for i := 0; i < count && remaining > 0; i++ { |
| if constraints[i].IsUnbounded() || res[i] < constraints[i].Max { |
| res[i]++ |
| remaining-- |
| } |
| } |
| |
| return res |
| } |