nativelayout 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ """Flex layout engine in pure Python (no Kivy), used by the host.
2
+
3
+ It follows the Android host (LinearLayout + NLBox): columns and rows, `flex`, `gap`,
4
+ `justify-content`, `align-items`/`align-self`, `flex-wrap`, margins, padding, min/max and percentages.
5
+ `position: absolute` nodes are placed apart, by `layout_absolute`.
6
+
7
+ Each `LNode` has the style, the children and (for a leaf) a function `leaf(max_w) -> (w, h)` that measures
8
+ the content without padding. After `layout`, each node has `x, y, w, h` (outer box, relative to the
9
+ box of its parent).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from nativelayout.core.tree import (Align, Dim, Direction, Justify, Length, NativeStyle, Percent)
14
+
15
+ INF = float("inf")
16
+
17
+
18
+ class Metrics:
19
+ def __init__(self, density: float = 1.0, font_scale: float = 1.0):
20
+ self.density = density
21
+ self.font_scale = font_scale
22
+
23
+ def px(self, l: Length | None) -> float:
24
+ return 0.0 if l is None else l.to_pixels(self.density, self.font_scale)
25
+
26
+
27
+ class LNode:
28
+ def __init__(self, style: NativeStyle, children: list["LNode"] | None = None, leaf=None, key=None):
29
+ self.style = style
30
+ self.children = children if children is not None else []
31
+ self.leaf = leaf
32
+ self.key = key
33
+ self.x = self.y = self.w = self.h = 0.0
34
+
35
+
36
+ def _clamp(v: float, lo: float, hi: float) -> float:
37
+ return max(lo, min(hi, v))
38
+
39
+
40
+ def _limits(n: LNode, m: Metrics):
41
+ s = n.style
42
+ minw = m.px(s.min_width) if s.min_width is not None else 0.0
43
+ minh = m.px(s.min_height) if s.min_height is not None else 0.0
44
+ maxw = m.px(s.max_width) if s.max_width is not None else INF
45
+ maxh = m.px(s.max_height) if s.max_height is not None else INF
46
+ return minw, maxw, minh, maxh
47
+
48
+
49
+ def layout(root: LNode, m: Metrics, width: float, height: float, fill_height: bool = True) -> None:
50
+ """Places the tree inside `width` x `height` (the viewport). The root takes the whole width;
51
+ if the content is shorter, it stretches to the viewport (like a ScrollView with fillViewport)."""
52
+ s = root.style
53
+ mg = s.margin
54
+ ml, mr, mt, mb = m.px(mg.left), m.px(mg.right), m.px(mg.top), m.px(mg.bottom)
55
+ fw = m.px(s.width.length) if isinstance(s.width, Dim) else max(width - ml - mr, 0.0)
56
+ fh = None
57
+ if s.flex > 0 or (isinstance(s.height, Percent) and s.height.value >= 100):
58
+ fh = max(height - mt - mb, 0.0)
59
+ elif isinstance(s.height, Dim):
60
+ fh = m.px(s.height.length)
61
+ _lay(root, m, fw, INF, fw, fh)
62
+ if fh is None and fill_height and root.h < height - mt - mb:
63
+ _lay(root, m, fw, INF, fw, height - mt - mb)
64
+ root.x, root.y = ml, mt
65
+
66
+
67
+ def _lay(n: LNode, m: Metrics, aw: float, ah: float, fw: float | None, fh: float | None):
68
+ """Measures and places `n`. `aw/ah`: maximum space; `fw/fh`: exact size imposed (or None)."""
69
+ st = n.style
70
+ minw, maxw, minh, maxh = _limits(n, m)
71
+ if fw is not None:
72
+ fw = _clamp(fw, minw, maxw)
73
+ if fh is not None:
74
+ fh = _clamp(fh, minh, maxh)
75
+ pl, pr = m.px(st.padding.left), m.px(st.padding.right)
76
+ pt, pb = m.px(st.padding.top), m.px(st.padding.bottom)
77
+ ph, pv = pl + pr, pt + pb
78
+ aw = min(aw, maxw)
79
+ ah = min(ah, maxh)
80
+ inner_aw = (fw if fw is not None else aw) - ph
81
+ inner_ah = (fh if fh is not None else ah) - pv
82
+
83
+ if n.leaf is not None or not n.children:
84
+ if n.leaf is not None:
85
+ cw, ch = n.leaf(inner_aw if inner_aw != INF else None)
86
+ else:
87
+ cw = ch = 0.0
88
+ w = fw if fw is not None else min(cw + ph, aw)
89
+ h = fh if fh is not None else min(ch + pv, ah if ah != INF else ch + pv)
90
+ n.w = _clamp(w, minw, maxw)
91
+ n.h = _clamp(h, minh, maxh)
92
+ return n.w, n.h
93
+
94
+ vertical = st.direction == Direction.COLUMN
95
+ gap = m.px(st.gap)
96
+ wrapped = st.wrap and not vertical
97
+
98
+ # main axis (M) and cross axis (C), from (width, height)
99
+ def split(w, h):
100
+ return (h, w) if vertical else (w, h)
101
+
102
+ def join(mn, cr):
103
+ return (cr, mn) if vertical else (mn, cr)
104
+
105
+ inner_main = (fh if vertical else fw)
106
+ inner_main = None if inner_main is None else inner_main - (pv if vertical else ph)
107
+ inner_cross = (fw if vertical else fh)
108
+ inner_cross = None if inner_cross is None else inner_cross - (ph if vertical else pv)
109
+ avail_main = (inner_ah if vertical else inner_aw)
110
+ avail_cross = (inner_aw if vertical else inner_ah)
111
+
112
+ kids = n.children
113
+
114
+ def margins(c):
115
+ g = c.style.margin
116
+ l, r, t, b = m.px(g.left), m.px(g.right), m.px(g.top), m.px(g.bottom)
117
+ return (t, b, l, r) if vertical else (l, r, t, b) # (main_before, main_after, cross_before, cross_after)
118
+
119
+ def child_specs(c):
120
+ cs = c.style
121
+ main_sz, cross_sz = (cs.height, cs.width) if vertical else (cs.width, cs.height)
122
+ align = cs.align_self or st.align_items
123
+ mb, ma, cb, ca = margins(c)
124
+ # cross axis
125
+ cross_fixed = None
126
+ cross_fill = False
127
+ if isinstance(cross_sz, Dim):
128
+ cross_fixed = m.px(cross_sz.length)
129
+ elif isinstance(cross_sz, Percent):
130
+ if cross_sz.value >= 100:
131
+ cross_fill = True
132
+ elif inner_cross is not None:
133
+ cross_fixed = inner_cross * cross_sz.value / 100.0
134
+ elif align == Align.STRETCH:
135
+ cross_fill = True
136
+ if cross_fill and inner_cross is not None:
137
+ cross_fixed = max(inner_cross - cb - ca, 0.0)
138
+ cross_fill = False
139
+ # main axis
140
+ main_fixed = None
141
+ if isinstance(main_sz, Dim):
142
+ main_fixed = m.px(main_sz.length)
143
+ elif isinstance(main_sz, Percent) and inner_main is not None:
144
+ main_fixed = inner_main * (min(main_sz.value, 100.0)) / 100.0
145
+ flex = cs.flex if (cs.flex > 0 and not wrapped) else 0.0
146
+ return main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca)
147
+
148
+ def call(c, main_fixed, cross_fixed, avail_m, avail_c):
149
+ fw_, fh_ = join(main_fixed, cross_fixed)
150
+ aw_, ah_ = join(avail_m, avail_c)
151
+ return _lay(c, m, aw_, ah_, fw_, fh_)
152
+
153
+ specs = [child_specs(c) for c in kids]
154
+ sizes: list[tuple[float, float]] = [(0.0, 0.0)] * len(kids) # (main, cross) of each child
155
+
156
+ if wrapped:
157
+ return _lay_wrap(n, m, kids, specs, call, inner_aw, aw, ah, fw, fh, st, pl, pt, ph, pv, gap,
158
+ minw, maxw, minh, maxh)
159
+
160
+ # step 1: children without flex
161
+ total_main = 0.0
162
+ flex_sum = 0.0
163
+ for i, c in enumerate(kids):
164
+ main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca) = specs[i]
165
+ if flex > 0:
166
+ flex_sum += flex
167
+ total_main += mb + ma
168
+ continue
169
+ avail_c = (avail_cross - cb - ca) if avail_cross != INF else INF
170
+ avail_m = (avail_main - total_main - mb - ma) if avail_main != INF else INF
171
+ w, h = call(c, main_fixed, cross_fixed, max(avail_m, 0.0) if avail_m != INF else INF, max(avail_c, 0.0) if avail_c != INF else INF)
172
+ mn, cr = split(w, h)
173
+ sizes[i] = (mn, cr)
174
+ total_main += mn + mb + ma
175
+ count = len(kids)
176
+ gaps = gap * (count - 1) if count > 1 else 0.0
177
+ total_main += gaps
178
+
179
+ # step 2: children with flex (they only grow if the main axis of the parent is exact)
180
+ if flex_sum > 0:
181
+ free = (inner_main - total_main) if inner_main is not None else 0.0
182
+ free = max(free, 0.0)
183
+ for i, c in enumerate(kids):
184
+ main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca) = specs[i]
185
+ if flex <= 0:
186
+ continue
187
+ share = free * flex / flex_sum
188
+ avail_c = (avail_cross - cb - ca) if avail_cross != INF else INF
189
+ w, h = call(c, share, cross_fixed, share, max(avail_c, 0.0) if avail_c != INF else INF)
190
+ mn, cr = split(w, h)
191
+ sizes[i] = (mn, cr)
192
+ total_main += mn
193
+ # main size of the parent
194
+ content_main = total_main
195
+ if inner_main is not None:
196
+ main_size = inner_main
197
+ else:
198
+ main_size = min(content_main, avail_main) if avail_main != INF else content_main
199
+ # cross size of the parent
200
+ max_cross = 0.0
201
+ for i in range(count):
202
+ _, _, _, _, _, (mb, ma, cb, ca) = specs[i]
203
+ max_cross = max(max_cross, sizes[i][1] + cb + ca)
204
+ cross_size = inner_cross if inner_cross is not None else max_cross
205
+ if inner_cross is None and avail_cross != INF:
206
+ cross_size = min(cross_size, avail_cross)
207
+
208
+ # children with a "stretch" cross axis that ended up narrower than the parent are measured again
209
+ for i, c in enumerate(kids):
210
+ main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca) = specs[i]
211
+ if cross_fill:
212
+ target = max(cross_size - cb - ca, 0.0)
213
+ if abs(sizes[i][1] - target) > 0.5:
214
+ w, h = call(c, sizes[i][0], target, sizes[i][0], target)
215
+ sizes[i] = split(w, h)
216
+
217
+ # justify
218
+ free = max(main_size - content_main, 0.0)
219
+ j = st.justify
220
+ start = 0.0
221
+ between = gap
222
+ if count:
223
+ if j == Justify.CENTER:
224
+ start = free / 2
225
+ elif j == Justify.END:
226
+ start = free
227
+ elif j == Justify.BETWEEN and count > 1:
228
+ between = gap + free / (count - 1)
229
+ elif j == Justify.AROUND:
230
+ start = free / (2 * count)
231
+ between = gap + free / count
232
+ elif j == Justify.EVENLY:
233
+ start = free / (count + 1)
234
+ between = gap + free / (count + 1)
235
+ pos = start
236
+ pm, pc = (pt, pl) if vertical else (pl, pt)
237
+ for i, c in enumerate(kids):
238
+ main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca) = specs[i]
239
+ pos += mb
240
+ mn, cr = sizes[i]
241
+ if align == Align.CENTER:
242
+ cpos = (cross_size - cr - cb - ca) / 2 + cb
243
+ elif align == Align.END:
244
+ cpos = cross_size - cr - ca
245
+ else:
246
+ cpos = cb
247
+ if vertical:
248
+ c.x, c.y = pc + cpos, pm + pos
249
+ else:
250
+ c.x, c.y = pm + pos, pc + cpos
251
+ pos += mn + ma + between
252
+
253
+ w, h = join(main_size, cross_size)
254
+ w += ph
255
+ h += pv
256
+ n.w = _clamp(fw if fw is not None else w, minw, maxw)
257
+ n.h = _clamp(fh if fh is not None else h, minh, maxh)
258
+ return n.w, n.h
259
+
260
+
261
+ def _lay_wrap(n, m, kids, specs, call, inner_aw, aw, ah, fw, fh, st, pl, pt, ph, pv, gap,
262
+ minw, maxw, minh, maxh):
263
+ """A row with `flex-wrap: wrap`: flex does not grow, each child keeps its width."""
264
+ avail = inner_aw if inner_aw != INF else INF
265
+ lines: list[list[tuple[int, float, float]]] = [[]]
266
+ line_w = 0.0
267
+ for i, c in enumerate(kids):
268
+ main_fixed, cross_fixed, cross_fill, flex, align, (mb, ma, cb, ca) = specs[i]
269
+ w, h = call(c, main_fixed, cross_fixed, avail if avail != INF else INF, INF)
270
+ cw = w + mb + ma
271
+ ch = h + cb + ca
272
+ if lines[-1] and line_w + gap + cw > avail:
273
+ lines.append([])
274
+ line_w = 0.0
275
+ line_w += (gap if lines[-1] else 0.0) + cw
276
+ lines[-1].append((i, w, h))
277
+ y = 0.0
278
+ max_w = 0.0
279
+ inner_w = (fw - ph) if fw is not None else None
280
+ for line in lines:
281
+ if not line:
282
+ continue
283
+ lh = max(h + specs[i][5][2] + specs[i][5][3] for i, w, h in line)
284
+ tw = sum(w + specs[i][5][0] + specs[i][5][1] for i, w, h in line) + gap * (len(line) - 1)
285
+ free = max((inner_w if inner_w is not None else tw) - tw, 0.0)
286
+ j = st.justify
287
+ x = free / 2 if j == Justify.CENTER else free if j == Justify.END else 0.0
288
+ between = gap
289
+ k = len(line)
290
+ if j == Justify.BETWEEN and k > 1:
291
+ between = gap + free / (k - 1)
292
+ elif j == Justify.AROUND:
293
+ x = free / (2 * k)
294
+ between = gap + free / k
295
+ elif j == Justify.EVENLY:
296
+ x = free / (k + 1)
297
+ between = gap + free / (k + 1)
298
+ for i, w, h in line:
299
+ c = kids[i]
300
+ mb, ma, cb, ca = specs[i][5]
301
+ align = specs[i][4]
302
+ x += mb
303
+ if align == Align.CENTER:
304
+ cy = (lh - h - cb - ca) / 2 + cb
305
+ elif align == Align.END:
306
+ cy = lh - h - ca
307
+ else:
308
+ cy = cb
309
+ c.x, c.y = pl + x, pt + y + cy
310
+ x += w + ma + between
311
+ max_w = max(max_w, tw)
312
+ y += lh + gap
313
+ total_h = max(y - gap, 0.0)
314
+ w = (fw if fw is not None else max_w + ph)
315
+ h = (fh if fh is not None else total_h + pv)
316
+ n.w = _clamp(w, minw, maxw)
317
+ n.h = _clamp(h, minh, maxh)
318
+ return n.w, n.h
319
+
320
+
321
+ def lay_node(n: LNode, m: Metrics, aw: float, ah: float, fw: float | None, fh: float | None):
322
+ """Measures and places a node with the limits/exact size given (used by header, footer and layers)."""
323
+ return _lay(n, m, aw, ah, fw, fh)
324
+
325
+
326
+ def layout_absolute(n: LNode, m: Metrics, pw: float, ph: float) -> None:
327
+ """Places a `position: absolute` node inside a parent box of `pw` x `ph` (its outer box).
328
+
329
+ It follows the Android host (AbsFrame): `left/top/right/bottom` become offsets from the named edge,
330
+ added to the margin. With both `left` and `right` (or `top` and `bottom`) and no size it stretches.
331
+ With only `right` (or only `bottom`) it sits against that edge. The node takes no space in the flow.
332
+ After the call `n.x, n.y, n.w, n.h` are relative to the parent box.
333
+ """
334
+ s = n.style
335
+ left = m.px(s.left) if s.left is not None else None
336
+ right = m.px(s.right) if s.right is not None else None
337
+ top = m.px(s.top) if s.top is not None else None
338
+ bottom = m.px(s.bottom) if s.bottom is not None else None
339
+ mg = s.margin
340
+ ml, mr, mt, mb = m.px(mg.left), m.px(mg.right), m.px(mg.top), m.px(mg.bottom)
341
+
342
+ avail_w = max(pw - ml - mr - (left or 0.0) - (right or 0.0), 0.0)
343
+ avail_h = max(ph - mt - mb - (top or 0.0) - (bottom or 0.0), 0.0)
344
+
345
+ fw = None
346
+ if isinstance(s.width, Dim):
347
+ fw = m.px(s.width.length)
348
+ elif (isinstance(s.width, Percent) and s.width.value >= 100) or (left is not None and right is not None):
349
+ fw = avail_w
350
+ fh = None
351
+ if isinstance(s.height, Dim):
352
+ fh = m.px(s.height.length)
353
+ elif (isinstance(s.height, Percent) and s.height.value >= 100) or (top is not None and bottom is not None):
354
+ fh = avail_h
355
+
356
+ _lay(n, m, avail_w, avail_h, fw, fh)
357
+
358
+ if right is not None and left is None:
359
+ n.x = pw - mr - right - n.w
360
+ else:
361
+ n.x = ml + (left or 0.0)
362
+ if bottom is not None and top is None:
363
+ n.y = ph - mb - bottom - n.h
364
+ else:
365
+ n.y = mt + (top or 0.0)