1"""
2Layout dimensions are used to give the minimum, maximum and preferred
3dimensions for containers and controls.
4"""
5
6from __future__ import annotations
7
8from collections.abc import Callable
9from typing import TYPE_CHECKING
10
11__all__ = [
12 "Dimension",
13 "D",
14 "sum_layout_dimensions",
15 "max_layout_dimensions",
16 "AnyDimension",
17 "to_dimension",
18 "is_dimension",
19]
20
21if TYPE_CHECKING:
22 from typing import TypeGuard
23
24
25class Dimension:
26 """
27 Specified dimension (width/height) of a user control or window.
28
29 The layout engine tries to honor the preferred size. If that is not
30 possible, because the terminal is larger or smaller, it tries to keep in
31 between min and max.
32
33 :param min: Minimum size.
34 :param max: Maximum size.
35 :param weight: For a VSplit/HSplit, the actual size will be determined
36 by taking the proportion of weights from all the children.
37 E.g. When there are two children, one with a weight of 1,
38 and the other with a weight of 2, the second will always be
39 twice as big as the first, if the min/max values allow it.
40 :param preferred: Preferred size.
41 """
42
43 def __init__(
44 self,
45 min: int | None = None,
46 max: int | None = None,
47 weight: int | None = None,
48 preferred: int | None = None,
49 ) -> None:
50 if weight is not None:
51 assert weight >= 0 # Also cannot be a float.
52
53 assert min is None or min >= 0
54 assert max is None or max >= 0
55 assert preferred is None or preferred >= 0
56
57 self.min_specified = min is not None
58 self.max_specified = max is not None
59 self.preferred_specified = preferred is not None
60 self.weight_specified = weight is not None
61
62 if min is None:
63 min = 0 # Smallest possible value.
64 if max is None: # 0-values are allowed, so use "is None"
65 max = 1000**10 # Something huge.
66 if preferred is None:
67 preferred = min
68 if weight is None:
69 weight = 1
70
71 self.min = min
72 self.max = max
73 self.preferred = preferred
74 self.weight = weight
75
76 # Don't allow situations where max < min. (This would be a bug.)
77 if max < min:
78 raise ValueError("Invalid Dimension: max < min.")
79
80 # Make sure that the 'preferred' size is always in the min..max range.
81 if self.preferred < self.min:
82 self.preferred = self.min
83
84 if self.preferred > self.max:
85 self.preferred = self.max
86
87 @classmethod
88 def exact(cls, amount: int) -> Dimension:
89 """
90 Return a :class:`.Dimension` with an exact size. (min, max and
91 preferred set to ``amount``).
92 """
93 return cls(min=amount, max=amount, preferred=amount)
94
95 @classmethod
96 def zero(cls) -> Dimension:
97 """
98 Create a dimension that represents a zero size. (Used for 'invisible'
99 controls.)
100 """
101 return cls.exact(amount=0)
102
103 def __repr__(self) -> str:
104 fields = []
105 if self.min_specified:
106 fields.append(f"min={self.min!r}")
107 if self.max_specified:
108 fields.append(f"max={self.max!r}")
109 if self.preferred_specified:
110 fields.append(f"preferred={self.preferred!r}")
111 if self.weight_specified:
112 fields.append(f"weight={self.weight!r}")
113
114 return "Dimension({})".format(", ".join(fields))
115
116
117def sum_layout_dimensions(dimensions: list[Dimension]) -> Dimension:
118 """
119 Sum a list of :class:`.Dimension` instances.
120 """
121 min = sum(d.min for d in dimensions)
122 max = sum(d.max for d in dimensions)
123 preferred = sum(d.preferred for d in dimensions)
124
125 return Dimension(min=min, max=max, preferred=preferred)
126
127
128def max_layout_dimensions(dimensions: list[Dimension]) -> Dimension:
129 """
130 Take the maximum of a list of :class:`.Dimension` instances.
131 Used when we have a HSplit/VSplit, and we want to get the best width/height.)
132 """
133 if not len(dimensions):
134 return Dimension.zero()
135
136 # If all dimensions are size zero. Return zero.
137 # (This is important for HSplit/VSplit, to report the right values to their
138 # parent when all children are invisible.)
139 if all(d.preferred == 0 and d.max == 0 for d in dimensions):
140 return Dimension.zero()
141
142 # Ignore empty dimensions. (They should not reduce the size of others.)
143 dimensions = [d for d in dimensions if d.preferred != 0 and d.max != 0]
144
145 if dimensions:
146 # Take the highest minimum dimension.
147 min_ = max(d.min for d in dimensions)
148
149 # For the maximum, we would prefer not to go larger than then smallest
150 # 'max' value, unless other dimensions have a bigger preferred value.
151 # This seems to work best:
152 # - We don't want that a widget with a small height in a VSplit would
153 # shrink other widgets in the split.
154 # If it doesn't work well enough, then it's up to the UI designer to
155 # explicitly pass dimensions.
156 max_ = min(d.max for d in dimensions)
157 max_ = max(max_, max(d.preferred for d in dimensions))
158
159 # Make sure that min>=max. In some scenarios, when certain min..max
160 # ranges don't have any overlap, we can end up in such an impossible
161 # situation. In that case, give priority to the max value.
162 # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8).
163 if min_ > max_:
164 max_ = min_
165
166 preferred = max(d.preferred for d in dimensions)
167
168 return Dimension(min=min_, max=max_, preferred=preferred)
169 else:
170 return Dimension()
171
172
173# Anything that can be converted to a dimension
174AnyDimension = None | int | Dimension | Callable[[], "AnyDimension"]
175
176
177def to_dimension(value: AnyDimension) -> Dimension:
178 """
179 Turn the given object into a `Dimension` object.
180 """
181 if value is None:
182 return Dimension()
183 if isinstance(value, int):
184 return Dimension.exact(value)
185 if isinstance(value, Dimension):
186 return value
187 if callable(value):
188 return to_dimension(value())
189
190 raise ValueError("Not an integer or Dimension object.")
191
192
193def is_dimension(value: object) -> TypeGuard[AnyDimension]:
194 """
195 Test whether the given value could be a valid dimension.
196 (For usage in an assertion. It's not guaranteed in case of a callable.)
197 """
198 if value is None:
199 return True
200 if callable(value):
201 return True # Assume it's a callable that doesn't take arguments.
202 if isinstance(value, (int, Dimension)):
203 return True
204 return False
205
206
207# Common alias.
208D = Dimension
209
210# For backward-compatibility.
211LayoutDimension = Dimension