blob: 898448342f292e4d356336818f87e2b3e5508f43 [file] [edit]
// 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
}