figkit 0.1.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.
figkit/layout.py ADDED
@@ -0,0 +1,529 @@
1
+ """Arrangement helpers: align, distribute, stack, grid and fit.
2
+
3
+ Nothing here is automatic layout — every function positions elements once,
4
+ when you call it, and then gets out of the way.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from .components import Panel
12
+ from .core import Element, Group
13
+ from .geom import BBox, Point, to_point
14
+
15
+ __all__ = [
16
+ "align", "align_h", "align_v", "distribute_h", "distribute_v",
17
+ "spread_h", "spread_v", "hstack", "vstack", "grid", "fit", "frame_around",
18
+ "between", "midpoint", "center_on", "group", "bbox_of", "circular",
19
+ "same_width", "same_height", "same_size", "shift", "baseline_of",
20
+ "brace_around",
21
+ ]
22
+
23
+ _EDGE_AXIS = {
24
+ "left": "x", "w": "x", "right": "x", "e": "x",
25
+ "center_x": "x", "cx": "x", "middle_x": "x",
26
+ "top": "y", "n": "y", "bottom": "y", "s": "y",
27
+ "center_y": "y", "cy": "y", "middle_y": "y",
28
+ }
29
+
30
+
31
+ def bbox_of(items) -> BBox:
32
+ """Union bbox of a list of elements/anchors/points."""
33
+ boxes = []
34
+ for it in _iter(items):
35
+ bb = getattr(it, "bbox", None)
36
+ if isinstance(bb, BBox):
37
+ boxes.append(bb)
38
+ else:
39
+ p = to_point(it)
40
+ boxes.append(BBox(p.x, p.y, 0, 0))
41
+ return BBox.union_all(boxes) or BBox(0, 0, 0, 0)
42
+
43
+
44
+ def _iter(items):
45
+ if items is None:
46
+ return []
47
+ if isinstance(items, (list, tuple, set)):
48
+ out = []
49
+ for i in items:
50
+ out.extend(_iter(i))
51
+ return out
52
+ if isinstance(items, Group) and not isinstance(items, Element):
53
+ return list(items)
54
+ return [items]
55
+
56
+
57
+ # ==========================================================================
58
+ # Alignment
59
+ # ==========================================================================
60
+
61
+ def align(items, edge: str = "top", to=None) -> list:
62
+ """Line elements up on one edge (or centre axis).
63
+
64
+ ``edge`` is one of ``top/bottom/left/right/center_x/center_y/center``.
65
+ ``to`` is the reference (an element, point or bbox); defaults to the first
66
+ item.
67
+
68
+ >>> align([a, b, c], "top")
69
+ >>> align([a, b], "center_x", to=title)
70
+ """
71
+ els = [e for e in _iter(items) if isinstance(e, Element)]
72
+ if not els:
73
+ return els
74
+ e = str(edge).lower()
75
+ ref = els[0] if to is None else to
76
+ ref_bb = ref if isinstance(ref, BBox) else bbox_of(ref)
77
+ if e in ("center", "middle"):
78
+ for el in els:
79
+ el.move(ref_bb.cx - el.bbox.cx, ref_bb.cy - el.bbox.cy)
80
+ return els
81
+ if e not in _EDGE_AXIS:
82
+ raise ValueError(f"align edge={edge!r}; use one of "
83
+ f"{sorted(set(_EDGE_AXIS) | {'center'})}")
84
+ for el in els:
85
+ bb = el.bbox
86
+ if e in ("left", "w"):
87
+ el.move(ref_bb.x0 - bb.x0, 0)
88
+ elif e in ("right", "e"):
89
+ el.move(ref_bb.x1 - bb.x1, 0)
90
+ elif e in ("center_x", "cx", "middle_x"):
91
+ el.move(ref_bb.cx - bb.cx, 0)
92
+ elif e in ("top", "n"):
93
+ el.move(0, ref_bb.y0 - bb.y0)
94
+ elif e in ("bottom", "s"):
95
+ el.move(0, ref_bb.y1 - bb.y1)
96
+ else:
97
+ el.move(0, ref_bb.cy - bb.cy)
98
+ return els
99
+
100
+
101
+ def align_h(items, edge: str = "center_y", to=None) -> list:
102
+ """Align on a horizontal line (i.e. equalise ``y``)."""
103
+ return align(items, edge, to)
104
+
105
+
106
+ def align_v(items, edge: str = "center_x", to=None) -> list:
107
+ """Align on a vertical line (i.e. equalise ``x``)."""
108
+ return align(items, edge, to)
109
+
110
+
111
+ # ==========================================================================
112
+ # Distribution
113
+ # ==========================================================================
114
+
115
+ def distribute_h(items, gap: float = 20.0, start=None, align_edge=None) -> list:
116
+ """Lay items out left-to-right with a fixed ``gap`` between them.
117
+
118
+ Keeps the first item where it is unless ``start`` (an x coordinate) is given.
119
+
120
+ >>> distribute_h([a, b, c], gap=24)
121
+ """
122
+ els = [e for e in _iter(items) if isinstance(e, Element)]
123
+ if not els:
124
+ return els
125
+ x = els[0].bbox.x0 if start is None else float(start)
126
+ for el in els:
127
+ el.move(x - el.bbox.x0, 0)
128
+ x = el.bbox.x1 + gap
129
+ if align_edge:
130
+ align(els, align_edge, to=els[0])
131
+ return els
132
+
133
+
134
+ def distribute_v(items, gap: float = 20.0, start=None, align_edge=None) -> list:
135
+ """Lay items out top-to-bottom with a fixed ``gap``."""
136
+ els = [e for e in _iter(items) if isinstance(e, Element)]
137
+ if not els:
138
+ return els
139
+ y = els[0].bbox.y0 if start is None else float(start)
140
+ for el in els:
141
+ el.move(0, y - el.bbox.y0)
142
+ y = el.bbox.y1 + gap
143
+ if align_edge:
144
+ align(els, align_edge, to=els[0])
145
+ return els
146
+
147
+
148
+ def spread_h(items, x0=None, x1=None, mode: str = "edges") -> list:
149
+ """Spread items evenly across a horizontal span.
150
+
151
+ ``mode="edges"`` gives equal gaps between bounding boxes;
152
+ ``mode="centers"`` spaces the centres evenly (good for equal-width items).
153
+ """
154
+ els = [e for e in _iter(items) if isinstance(e, Element)]
155
+ if len(els) < 2:
156
+ return els
157
+ els.sort(key=lambda e: e.bbox.cx)
158
+ lo = els[0].bbox.x0 if x0 is None else _coord(x0, "x")
159
+ hi = els[-1].bbox.x1 if x1 is None else _coord(x1, "x")
160
+ if mode == "centers":
161
+ c0 = els[0].bbox.cx if x0 is None else lo
162
+ c1 = els[-1].bbox.cx if x1 is None else hi
163
+ step = (c1 - c0) / (len(els) - 1)
164
+ for i, el in enumerate(els):
165
+ el.move(c0 + i * step - el.bbox.cx, 0)
166
+ return els
167
+ total_w = sum(e.bbox.w for e in els)
168
+ gap = ((hi - lo) - total_w) / (len(els) - 1)
169
+ x = lo
170
+ for el in els:
171
+ el.move(x - el.bbox.x0, 0)
172
+ x = el.bbox.x1 + gap
173
+ return els
174
+
175
+
176
+ def spread_v(items, y0=None, y1=None, mode: str = "edges") -> list:
177
+ """Spread items evenly across a vertical span."""
178
+ els = [e for e in _iter(items) if isinstance(e, Element)]
179
+ if len(els) < 2:
180
+ return els
181
+ els.sort(key=lambda e: e.bbox.cy)
182
+ lo = els[0].bbox.y0 if y0 is None else _coord(y0, "y")
183
+ hi = els[-1].bbox.y1 if y1 is None else _coord(y1, "y")
184
+ if mode == "centers":
185
+ c0 = els[0].bbox.cy if y0 is None else lo
186
+ c1 = els[-1].bbox.cy if y1 is None else hi
187
+ step = (c1 - c0) / (len(els) - 1)
188
+ for i, el in enumerate(els):
189
+ el.move(0, c0 + i * step - el.bbox.cy)
190
+ return els
191
+ total_h = sum(e.bbox.h for e in els)
192
+ gap = ((hi - lo) - total_h) / (len(els) - 1)
193
+ y = lo
194
+ for el in els:
195
+ el.move(0, y - el.bbox.y0)
196
+ y = el.bbox.y1 + gap
197
+ return els
198
+
199
+
200
+ def _coord(value, axis: str) -> float:
201
+ if isinstance(value, (int, float)):
202
+ return float(value)
203
+ p = to_point(value)
204
+ return p.x if axis == "x" else p.y
205
+
206
+
207
+ # ==========================================================================
208
+ # Stacks & grids
209
+ # ==========================================================================
210
+
211
+ def hstack(items, gap: float = 16.0, align: str = "center", at=None,
212
+ pad=None, panel=False, name: str = None, **panel_style) -> Group:
213
+ """Place items in a row and return them as a movable :class:`Group`.
214
+
215
+ ``align`` is ``center`` / ``top`` / ``bottom``, or ``"baseline"`` to set
216
+ the items like an equation: text baselines line up, and items without text
217
+ (a matrix, an image) centre on the maths axis.
218
+
219
+ >>> row = hstack([a, b, c], gap=20, align="top")
220
+ >>> row.right_of(title, gap=30)
221
+ >>> formula = hstack([Text("$C =$"), matrix, Text("$\\phi$")],
222
+ ... gap=8, align="baseline")
223
+ """
224
+ els = [e for e in _iter(items) if isinstance(e, Element)]
225
+ if not els:
226
+ return Group(name=name)
227
+ if at is not None:
228
+ p = to_point(at)
229
+ els[0].at(p.x, p.y, anchor="nw")
230
+ distribute_h(els, gap=gap)
231
+ _align_cross(els, align, horizontal=True)
232
+ if panel or pad is not None or panel_style:
233
+ return fit(els, pad=16 if pad is None else pad, name=name,
234
+ **panel_style)
235
+ return Group(*els, name=name)
236
+
237
+
238
+ def vstack(items, gap: float = 12.0, align: str = "center", at=None,
239
+ pad=None, panel=False, name: str = None, **panel_style) -> Group:
240
+ """Place items in a column and return them as a movable :class:`Group`."""
241
+ els = [e for e in _iter(items) if isinstance(e, Element)]
242
+ if not els:
243
+ return Group(name=name)
244
+ if at is not None:
245
+ p = to_point(at)
246
+ els[0].at(p.x, p.y, anchor="nw")
247
+ distribute_v(els, gap=gap)
248
+ _align_cross(els, align, horizontal=False)
249
+ if panel or pad is not None or panel_style:
250
+ return fit(els, pad=16 if pad is None else pad, name=name,
251
+ **panel_style)
252
+ return Group(*els, name=name)
253
+
254
+
255
+ def baseline_of(element) -> float:
256
+ """The first text baseline inside ``element``, or ``None`` if it has none.
257
+
258
+ Looks through shapes to their labels and through groups to their first
259
+ baselined descendant, so a box, a bare label and a component all answer.
260
+ """
261
+ from .text import Text
262
+ if isinstance(element, Text):
263
+ return element.first_baseline
264
+ label = getattr(element, "label", None)
265
+ if isinstance(label, Text):
266
+ return label.first_baseline
267
+ for child in getattr(element, "_children", ()):
268
+ found = baseline_of(child)
269
+ if found is not None:
270
+ return found
271
+ return None
272
+
273
+
274
+ def _math_axis(element, baseline: float) -> float:
275
+ """Half an x-height above the baseline — where a formula's centre sits."""
276
+ from .fonts import get_font
277
+ from .text import Text
278
+ ref = element if isinstance(element, Text) else getattr(element, "label", None)
279
+ if ref is None:
280
+ return baseline
281
+ size = float(ref.prop("font_size", 14) or 14)
282
+ metrics = get_font(ref.prop("font_family"), ref.prop("font_weight"),
283
+ ref.prop("font_style")).metrics
284
+ return baseline - (metrics.x_height or 0.5) * size / 2.0
285
+
286
+
287
+ def _align_baselines(els) -> None:
288
+ """Line items up on their text baseline, as an equation would be set.
289
+
290
+ Items with no text of their own (a matrix, an image) are centred on the
291
+ maths axis instead, which is what reads correctly beside a symbol.
292
+ """
293
+ found = [(el, baseline_of(el)) for el in els]
294
+ reference = next(((el, b) for el, b in found if b is not None), None)
295
+ if reference is None:
296
+ ref_box = bbox_of(els)
297
+ for el in els:
298
+ el.move(0, ref_box.cy - el.bbox.cy)
299
+ return
300
+ ref_el, ref_baseline = reference
301
+ axis = _math_axis(ref_el, ref_baseline)
302
+ for el, baseline in found:
303
+ if baseline is not None:
304
+ el.move(0, ref_baseline - baseline)
305
+ else:
306
+ el.move(0, axis - el.bbox.cy)
307
+
308
+
309
+ def _align_cross(els, how: str, horizontal: bool) -> None:
310
+ if not how or str(how).lower() in ("none", "keep"):
311
+ return
312
+ a = str(how).lower()
313
+ if horizontal and a == "baseline":
314
+ _align_baselines(els)
315
+ return
316
+ if horizontal:
317
+ edge = {"top": "top", "n": "top", "bottom": "bottom", "s": "bottom",
318
+ "center": "center_y", "middle": "center_y"}.get(a)
319
+ else:
320
+ edge = {"left": "left", "w": "left", "right": "right", "e": "right",
321
+ "center": "center_x", "middle": "center_x"}.get(a)
322
+ if edge is None:
323
+ raise ValueError(f"align={how!r} is not valid for this stack")
324
+ ref = bbox_of(els)
325
+ align(els, edge, to=ref)
326
+
327
+
328
+ def grid(items, cols: int = None, rows: int = None, gap=16, at=None,
329
+ align: str = "center", equal: bool = True, name: str = None,
330
+ order: str = "row") -> Group:
331
+ """Arrange items in a grid and return a :class:`Group`.
332
+
333
+ ``gap`` is a scalar or ``(gap_x, gap_y)``. With ``equal=True`` every cell
334
+ is the size of the largest item, so columns line up.
335
+ """
336
+ els = [e for e in _iter(items) if isinstance(e, Element)]
337
+ if not els:
338
+ return Group(name=name)
339
+ n = len(els)
340
+ if cols is None and rows is None:
341
+ cols = math.ceil(math.sqrt(n))
342
+ if cols is None:
343
+ cols = math.ceil(n / rows)
344
+ rows = rows or math.ceil(n / cols)
345
+ gx, gy = (gap if isinstance(gap, (tuple, list)) else (gap, gap))
346
+ origin = to_point(at) if at is not None else bbox_of(els).anchor("nw")
347
+
348
+ if equal:
349
+ cw = max(e.bbox.w for e in els)
350
+ ch = max(e.bbox.h for e in els)
351
+ col_w = [cw] * cols
352
+ row_h = [ch] * rows
353
+ else:
354
+ col_w = [0.0] * cols
355
+ row_h = [0.0] * rows
356
+ for idx, el in enumerate(els):
357
+ r, c = _rc(idx, cols, rows, order)
358
+ col_w[c] = max(col_w[c], el.bbox.w)
359
+ row_h[r] = max(row_h[r], el.bbox.h)
360
+
361
+ xs = [origin.x]
362
+ for w in col_w[:-1]:
363
+ xs.append(xs[-1] + w + gx)
364
+ ys = [origin.y]
365
+ for h in row_h[:-1]:
366
+ ys.append(ys[-1] + h + gy)
367
+
368
+ for idx, el in enumerate(els):
369
+ r, c = _rc(idx, cols, rows, order)
370
+ cell = BBox(xs[c], ys[r], col_w[c], row_h[r])
371
+ anchor = {"center": "center", "left": "w", "right": "e",
372
+ "top": "n", "bottom": "s", "nw": "nw", "ne": "ne",
373
+ "sw": "sw", "se": "se"}.get(str(align).lower(), "center")
374
+ target = cell.anchor(anchor)
375
+ el.at(target.x, target.y, anchor=anchor)
376
+ return Group(*els, name=name)
377
+
378
+
379
+ def _rc(idx: int, cols: int, rows: int, order: str) -> tuple:
380
+ if str(order).startswith("col"):
381
+ return idx % rows, idx // rows
382
+ return idx // cols, idx % cols
383
+
384
+
385
+ # ==========================================================================
386
+ # Containers
387
+ # ==========================================================================
388
+
389
+ def fit(*items, pad=16, label=None, label_pos: str = "above_left",
390
+ name: str = None, style=None, **panel_style) -> Group:
391
+ """Wrap elements in a container box sized around them.
392
+
393
+ Returns a :class:`Group` holding the panel (behind) plus the items, so the
394
+ whole thing moves as a unit. ``result.panel`` is the box itself.
395
+
396
+ ``label_pos`` defaults to ``"above_left"`` (outside the box, where it
397
+ cannot collide with the contents); ``"nw"``, ``"n"``, ``"below"`` and the
398
+ other anchors also work.
399
+
400
+ >>> frame = fit(ipm, cmn, pad=20, label="Fused operation", dash=True)
401
+ >>> frame.below_of(header, gap=24)
402
+ """
403
+ els = [e for e in _iter(items) if isinstance(e, Element)]
404
+ panel = Panel(els, pad=pad, label=label, label_pos=label_pos,
405
+ style=style, add=False, **panel_style)
406
+ g = Group(panel, *els, name=name)
407
+ g.panel = panel
408
+ return g
409
+
410
+
411
+ def frame_around(items, pad=16, **style) -> Panel:
412
+ """Just the container box (no group), tracking ``items`` as they move."""
413
+ els = [e for e in _iter(items) if isinstance(e, Element)]
414
+ return Panel(els, pad=pad, **style)
415
+
416
+
417
+ def brace_around(items, side: str = "top", gap: float = 8.0,
418
+ depth: float = 10.0, label=None, pad: float = 0.0,
419
+ **kw):
420
+ """A :class:`~figkit.components.Brace` spanning a set of elements.
421
+
422
+ >>> brace_around([a, b, c], side="top", label="encoder")
423
+ """
424
+ from .components import Brace
425
+ box = bbox_of(items).expand(pad)
426
+ s = str(side).lower()
427
+ if s in ("top", "n", "above"):
428
+ start, end = (box.x0, box.y0 - gap), (box.x1, box.y0 - gap)
429
+ facing = "up"
430
+ elif s in ("bottom", "s", "below"):
431
+ start, end = (box.x1, box.y1 + gap), (box.x0, box.y1 + gap)
432
+ facing = "down"
433
+ elif s in ("left", "w"):
434
+ start, end = (box.x0 - gap, box.y0), (box.x0 - gap, box.y1)
435
+ facing = "left"
436
+ elif s in ("right", "e"):
437
+ start, end = (box.x1 + gap, box.y1), (box.x1 + gap, box.y0)
438
+ facing = "right"
439
+ else:
440
+ raise ValueError(f"side={side!r}; use top/bottom/left/right")
441
+ return Brace(start, end, depth=depth, side=facing, label=label, **kw)
442
+
443
+
444
+ def group(*items, name: str = None, **kw) -> Group:
445
+ """Bundle elements into a :class:`Group`."""
446
+ return Group(*[e for e in _iter(items)], name=name, **kw)
447
+
448
+
449
+ # ==========================================================================
450
+ # Point helpers
451
+ # ==========================================================================
452
+
453
+ def between(a, b, t: float = 0.5) -> Point:
454
+ """The point ``t`` of the way from ``a`` to ``b`` (centres by default).
455
+
456
+ >>> mid = between(fm, fn) # vertically between two boxes
457
+ >>> arrow(solver.e, mid)
458
+ """
459
+ return to_point(a).lerp(to_point(b), t)
460
+
461
+
462
+ midpoint = between
463
+
464
+
465
+ def center_on(el: Element, target, axis: str = "both") -> Element:
466
+ """Centre ``el`` on ``target`` (an element, anchor or point)."""
467
+ bb = bbox_of(target)
468
+ a = str(axis).lower()
469
+ if a in ("both", "xy"):
470
+ return el.center_at(bb.cx, bb.cy)
471
+ if a == "x":
472
+ return el.move(bb.cx - el.bbox.cx, 0)
473
+ if a == "y":
474
+ return el.move(0, bb.cy - el.bbox.cy)
475
+ raise ValueError(f"axis={axis!r}; use both/x/y")
476
+
477
+
478
+ def circular(items, center=(0, 0), radius: float = 120.0,
479
+ start_angle: float = -90.0, sweep: float = 360.0,
480
+ rotate_items: bool = False) -> list:
481
+ """Place items evenly around a circle (cycle diagrams)."""
482
+ els = [e for e in _iter(items) if isinstance(e, Element)]
483
+ if not els:
484
+ return els
485
+ c = to_point(center)
486
+ n = len(els)
487
+ step = sweep / n if abs(sweep) >= 359.99 else sweep / max(1, n - 1)
488
+ for i, el in enumerate(els):
489
+ a = math.radians(start_angle + i * step)
490
+ el.center_at(c.x + math.cos(a) * radius, c.y + math.sin(a) * radius)
491
+ if rotate_items:
492
+ el.rotate(start_angle + i * step + 90)
493
+ return els
494
+
495
+
496
+ def shift(items, dx: float = 0.0, dy: float = 0.0) -> list:
497
+ """Move several elements at once."""
498
+ els = [e for e in _iter(items) if isinstance(e, Element)]
499
+ for el in els:
500
+ el.move(dx, dy)
501
+ return els
502
+
503
+
504
+ def same_width(items, width: float = None, anchor: str = "center") -> list:
505
+ """Give every item the same width (the widest, unless ``width`` is given)."""
506
+ els = [e for e in _iter(items) if isinstance(e, Element)]
507
+ if not els:
508
+ return els
509
+ w = width if width is not None else max(e.bbox.w for e in els)
510
+ for el in els:
511
+ el.resize(w=w, anchor=anchor)
512
+ return els
513
+
514
+
515
+ def same_height(items, height: float = None, anchor: str = "center") -> list:
516
+ """Give every item the same height (the tallest, unless given)."""
517
+ els = [e for e in _iter(items) if isinstance(e, Element)]
518
+ if not els:
519
+ return els
520
+ h = height if height is not None else max(e.bbox.h for e in els)
521
+ for el in els:
522
+ el.resize(h=h, anchor=anchor)
523
+ return els
524
+
525
+
526
+ def same_size(items, anchor: str = "center") -> list:
527
+ """Give every item the same width *and* height."""
528
+ same_width(items, anchor=anchor)
529
+ return same_height(items, anchor=anchor)