predictygraph 0.2.4__tar.gz → 0.2.5__tar.gz

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.
Files changed (54) hide show
  1. {predictygraph-0.2.4 → predictygraph-0.2.5}/PKG-INFO +1 -1
  2. {predictygraph-0.2.4 → predictygraph-0.2.5}/pyproject.toml +1 -1
  3. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/layout.py +13 -10
  4. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/scene.py +44 -3
  5. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_annotations.py +19 -0
  6. {predictygraph-0.2.4 → predictygraph-0.2.5}/README.md +0 -0
  7. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/__init__.py +0 -0
  8. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/__main__.py +0 -0
  9. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/anim.py +0 -0
  10. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/cli.py +0 -0
  11. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/default.css +0 -0
  12. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  13. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  14. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  15. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  16. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  17. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  18. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  19. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  20. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  21. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  22. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/fonts.py +0 -0
  23. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/ir.py +0 -0
  24. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/model.py +0 -0
  25. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/parser.py +0 -0
  26. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/render/__init__.py +0 -0
  27. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/render/apng.py +0 -0
  28. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/render/svg.py +0 -0
  29. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/route.py +0 -0
  30. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/safe.py +0 -0
  31. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/style.py +0 -0
  32. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/styleutil.py +0 -0
  33. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/text.py +0 -0
  34. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/theme.py +0 -0
  35. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  36. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  37. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  38. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  39. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/default.toml +0 -0
  40. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/dracula.toml +0 -0
  41. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/github-light.toml +0 -0
  42. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  43. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/hami.toml +0 -0
  44. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/nord.toml +0 -0
  45. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/solarized-dark.toml +0 -0
  46. {predictygraph-0.2.4 → predictygraph-0.2.5}/src/predictygraph/themes/solarized-light.toml +0 -0
  47. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_diagonal.py +0 -0
  48. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_fixes.py +0 -0
  49. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_hardening.py +0 -0
  50. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_predictygraph.py +0 -0
  51. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_routing.py +0 -0
  52. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_scene.py +0 -0
  53. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_stack.py +0 -0
  54. {predictygraph-0.2.4 → predictygraph-0.2.5}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
5
5
  Author-Email: Reza Jelveh <git@jelveh.me>
6
6
  License: GPL-3.0-or-later
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.2.4"
15
+ version = "0.2.5"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -144,14 +144,13 @@ class LineLayout(Layout):
144
144
  return (pos, cross) if horizontal else (cross, pos)
145
145
 
146
146
  def label_gaps(self, horizontal: bool) -> dict[int, float]:
147
- """Gap needed after child i so labels of edges between neighbours i and i+1 fit
148
- (a row only: a label runs along the gap). Room = label width + arrowhead + margin."""
149
- if not horizontal:
150
- return {}
147
+ """Gap needed after child i so what sits on the edges between neighbours i and i+1
148
+ fits. Row: the edge label (plus its step circle) runs along the gap. Column: a step
149
+ circle sits beside the edge, so the gap must be at least one circle plus margins."""
151
150
  index = {k.id: i for i, k in enumerate(self.kids)}
152
151
  need: dict[int, float] = {}
153
152
  for e in self.doc.edges:
154
- if not e.label:
153
+ if not (e.label if horizontal else e.attrs.get("step")):
155
154
  continue
156
155
  a, b = direct_child(self.doc.items[e.src], self.g), direct_child(self.doc.items[e.dst], self.g)
157
156
  if a is None or b is None or a is b:
@@ -159,11 +158,15 @@ class LineLayout(Layout):
159
158
  i, j = sorted((index[a.id], index[b.id]))
160
159
  if j != i + 1:
161
160
  continue
162
- w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
163
- if e.attrs.get("step"): # the step badge sits just left of the label
164
- bw, bh, _ = text_block(e.attrs["step"][:3], e.step_style or e.style, 11, bold=True)
165
- w += max(bw, bh) + 12
166
- need[i] = max(need.get(i, 0.0), w + 34)
161
+ step = 0.0
162
+ if e.attrs.get("step"): # circle diameter, as drawn by SceneBuilder.step_badge
163
+ sw, sh, _ = text_block(e.attrs["step"][:3], e.step_style or e.style, 11, bold=True)
164
+ step = max(sw, sh) + 6
165
+ if horizontal:
166
+ w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
167
+ need[i] = max(need.get(i, 0.0), w + (step + 6 if step else 0.0) + 34)
168
+ else:
169
+ need[i] = max(need.get(i, 0.0), step + 14)
167
170
  return need
168
171
 
169
172
 
@@ -477,8 +477,10 @@ class SceneBuilder:
477
477
  return out
478
478
 
479
479
  def step_badge(self, e, poly, label_box=None) -> list[Elem]:
480
- """Numbered circle on the edge, a third of the way along its path. With a label the
481
- badge sits just left of the label box, so neither covers the other."""
480
+ """Numbered circle for an edge. Next to the label when there is one; on the line a
481
+ third of the way along when the edge is long enough to hold it with room to spare;
482
+ otherwise beside the middle of the edge, so a short arrow between stacked boxes
483
+ stays a visible line and the circle never sits on a box."""
482
484
  st = e.step_style or e.style
483
485
  st = {**st, "font-weight": st.get("font-weight", "bold")}
484
486
  fs = font_size(st, 11)
@@ -488,12 +490,51 @@ class SceneBuilder:
488
490
  if label_box:
489
491
  cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
490
492
  else:
491
- cx, cy = point_along(poly, 1 / 3)
493
+ cx, cy = self._step_spot(e, poly, r)
492
494
  circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
493
495
  paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
494
496
  "stroke-width": 1.5})
495
497
  return [circ] + self.text([txt], cx, cy - fs * LINE_H / 2, fs, st)
496
498
 
499
+ STEP_ROOM = 3.0 # an edge must be this many circle diameters long to carry its step
500
+
501
+ def _step_spot(self, e, poly, r) -> tuple[float, float]:
502
+ """Where a label-less step circle goes (see step_badge)."""
503
+ length = sum(math.dist(a, b) for a, b in zip(poly, poly[1:]))
504
+ on_line = point_along(poly, 1 / 3)
505
+ if length >= self.STEP_ROOM * 2 * r and not self._on_a_box(on_line, r, e):
506
+ return on_line
507
+ mx, my = point_along(poly, 1 / 2)
508
+ # the segment holding the midpoint gives the edge's direction there
509
+ ax, ay, bx, by = *poly[0], *poly[-1]
510
+ acc, half = 0.0, length / 2
511
+ for a, b in zip(poly, poly[1:]):
512
+ seg = math.dist(a, b)
513
+ if acc + seg >= half:
514
+ (ax, ay), (bx, by) = a, b
515
+ break
516
+ acc += seg
517
+ d = math.hypot(bx - ax, by - ay) or 1.0
518
+ nx, ny = -(by - ay) / d, (bx - ax) / d # unit normal
519
+ off = r + 4
520
+ sides = [(mx - nx * off, my - ny * off), (mx + nx * off, my + ny * off)]
521
+ if abs(by - ay) >= abs(bx - ax): # vertical-ish edge: prefer the left side
522
+ sides.sort(key=lambda p: p[0])
523
+ else: # horizontal-ish edge: prefer above
524
+ sides.sort(key=lambda p: p[1])
525
+ free = [p for p in sides if not self._on_a_box(p, r, e)]
526
+ return (free or sides)[0]
527
+
528
+ def _on_a_box(self, p, r, e) -> bool:
529
+ """Would a circle of radius r at p overlap any node box (with a small margin)?"""
530
+ x, y = p
531
+ for n in self.doc.walk():
532
+ if isinstance(n, Group):
533
+ continue
534
+ if n.ax - r - 2 < x < n.ax + n.w + r + 2 and n.ay - r - 2 < y < n.ay + n.h + r + 2:
535
+ return True
536
+ return False
537
+
497
538
  @staticmethod
498
539
  def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
499
540
  """Arrowhead as plain geometry: tip sits exactly on the target outline.
@@ -105,3 +105,22 @@ def test_left_label_is_rotated_and_takes_a_column():
105
105
  rotated.append(e.geom["text"])
106
106
  stack += e.children
107
107
  assert rotated == ["Kernel space"]
108
+
109
+
110
+ def test_step_on_short_arrow_sits_beside_it_and_clear_of_boxes():
111
+ doc, sc = scene('graph layout=column gap=4 steps=auto\na "top box"\nb "bottom box"\na -> b\n')
112
+ circles = []
113
+ stack = list(sc.layers)
114
+ while stack:
115
+ e = stack.pop()
116
+ if e.kind == "circle" and "pdg-step" in (e.cls or ""):
117
+ circles.append(e.geom)
118
+ stack += e.children
119
+ (c,) = circles
120
+ for n in (doc.items["a"], doc.items["b"]):
121
+ inside_x = n.ax - c["r"] < c["cx"] < n.ax + n.w + c["r"]
122
+ inside_y = n.ay - c["r"] < c["cy"] < n.ay + n.h + c["r"]
123
+ assert not (inside_x and inside_y)
124
+ # the column gap grew to fit the circle, even though gap=4 was asked for
125
+ a, b = doc.items["a"], doc.items["b"]
126
+ assert b.ay - (a.ay + a.h) >= 2 * c["r"]
File without changes