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