graphrefly 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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""MeasurementAdapter implementations (roadmap §7.1 — pluggable backends).
|
|
2
|
+
|
|
3
|
+
All adapters satisfy the :class:`~graphrefly.extra.reactive_layout.MeasurementAdapter`
|
|
4
|
+
protocol. Sync constructors, sync ``measure_segment()`` — no async, no polling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Shared: East Asian Width detection for CLI adapter
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cell_width(ch: str) -> int:
|
|
20
|
+
"""Return display-cell width of a single character in a monospace terminal.
|
|
21
|
+
|
|
22
|
+
Combining marks (category M) → 0 cells; fullwidth / wide CJK → 2 cells;
|
|
23
|
+
everything else → 1 cell. Uses :func:`unicodedata.east_asian_width` and
|
|
24
|
+
:func:`unicodedata.category`.
|
|
25
|
+
|
|
26
|
+
Does not handle ZWJ emoji sequences (multi-codepoint clusters that
|
|
27
|
+
render as a single glyph) — terminal support for these varies widely.
|
|
28
|
+
"""
|
|
29
|
+
cat = unicodedata.category(ch)
|
|
30
|
+
# Combining marks (Mn, Mc, Me) + ZWJ → 0 cells
|
|
31
|
+
if cat.startswith("M") or ch == "\u200d":
|
|
32
|
+
return 0
|
|
33
|
+
eaw = unicodedata.east_asian_width(ch)
|
|
34
|
+
# W = Wide, F = Fullwidth
|
|
35
|
+
if eaw in ("W", "F"):
|
|
36
|
+
return 2
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _count_cells(text: str) -> int:
|
|
41
|
+
"""Count total display cells for *text* in a monospace terminal."""
|
|
42
|
+
return sum(_cell_width(ch) for ch in text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# CliMeasureAdapter
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CliMeasureAdapter:
|
|
51
|
+
"""Monospace terminal measurement adapter.
|
|
52
|
+
|
|
53
|
+
Width = cell count × ``cell_px``. CJK / fullwidth characters count as 2 cells.
|
|
54
|
+
No external dependencies. Works in any Python environment.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
cell_px:
|
|
59
|
+
Pixel width per terminal cell (default: 8).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_cell_px",)
|
|
63
|
+
|
|
64
|
+
def __init__(self, *, cell_px: float = 8) -> None:
|
|
65
|
+
self._cell_px = cell_px
|
|
66
|
+
|
|
67
|
+
def measure_segment(self, text: str, font: str) -> dict[str, float]:
|
|
68
|
+
"""Return ``{"width": <px>}`` for *text* using monospace cell counting."""
|
|
69
|
+
return {"width": _count_cells(text) * self._cell_px}
|
|
70
|
+
|
|
71
|
+
def clear_cache(self) -> None:
|
|
72
|
+
"""CliMeasureAdapter is stateless; this is a no-op hook."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# PrecomputedAdapter
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PrecomputedAdapter:
|
|
81
|
+
"""Pre-computed measurement adapter for SSR / snapshot replay.
|
|
82
|
+
|
|
83
|
+
Reads from a static metrics dict — zero measurement at runtime.
|
|
84
|
+
Ideal for server-side rendering or replaying snapshotted layouts.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
metrics:
|
|
89
|
+
``{font: {segment: width_px}}``. Outer key is the CSS font string;
|
|
90
|
+
inner key is the text segment.
|
|
91
|
+
fallback:
|
|
92
|
+
What to do when a segment is not found:
|
|
93
|
+
``"per-char"`` (default) — sum individual character widths.
|
|
94
|
+
``"error"`` — raise :class:`KeyError`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
__slots__ = ("_metrics", "_fallback")
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
metrics: dict[str, dict[str, float]],
|
|
102
|
+
*,
|
|
103
|
+
fallback: str = "per-char",
|
|
104
|
+
) -> None:
|
|
105
|
+
if fallback not in ("per-char", "error"):
|
|
106
|
+
raise ValueError(f"fallback must be 'per-char' or 'error', got {fallback!r}")
|
|
107
|
+
self._metrics = metrics
|
|
108
|
+
self._fallback = fallback
|
|
109
|
+
|
|
110
|
+
def measure_segment(self, text: str, font: str) -> dict[str, float]:
|
|
111
|
+
"""Return ``{"width": <px>}`` from pre-computed metrics."""
|
|
112
|
+
font_map = self._metrics.get(font)
|
|
113
|
+
if font_map is not None:
|
|
114
|
+
w = font_map.get(text)
|
|
115
|
+
if w is not None:
|
|
116
|
+
return {"width": w}
|
|
117
|
+
|
|
118
|
+
if self._fallback == "error":
|
|
119
|
+
raise KeyError(f"PrecomputedAdapter: no metrics for segment {text!r} in font {font!r}")
|
|
120
|
+
|
|
121
|
+
# per-char fallback: sum individual character widths
|
|
122
|
+
total = 0.0
|
|
123
|
+
if font_map is not None:
|
|
124
|
+
for ch in text:
|
|
125
|
+
cw = font_map.get(ch)
|
|
126
|
+
if cw is not None:
|
|
127
|
+
total += cw
|
|
128
|
+
return {"width": total}
|
|
129
|
+
|
|
130
|
+
def clear_cache(self) -> None:
|
|
131
|
+
"""PrecomputedAdapter is stateless; this is a no-op hook."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# PillowMeasureAdapter
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PillowMeasureAdapter:
|
|
140
|
+
"""Server-side measurement adapter using Pillow ``ImageFont.getlength()``.
|
|
141
|
+
|
|
142
|
+
Requires ``Pillow`` as an optional dependency. Font objects are cached
|
|
143
|
+
by ``(font_path, size)`` tuple.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
font_map:
|
|
148
|
+
``{css_font_string: (font_path, size)}`` mapping CSS font strings to
|
|
149
|
+
Pillow font constructor args. Example::
|
|
150
|
+
|
|
151
|
+
{"16px serif": ("/usr/share/fonts/serif.ttf", 16)}
|
|
152
|
+
|
|
153
|
+
fallback_font:
|
|
154
|
+
``(font_path, size)`` used when a CSS font string is not in *font_map*.
|
|
155
|
+
If ``None`` (default), Pillow's default font is used.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
__slots__ = ("_font_map", "_fallback_font", "_cache")
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
font_map: dict[str, tuple[str, int]] | None = None,
|
|
163
|
+
*,
|
|
164
|
+
fallback_font: tuple[str, int] | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self._font_map = font_map or {}
|
|
167
|
+
self._fallback_font = fallback_font
|
|
168
|
+
self._cache: dict[tuple[str, int] | None, Any] = {}
|
|
169
|
+
|
|
170
|
+
def _get_font(self, font: str) -> Any:
|
|
171
|
+
from PIL import ImageFont # type: ignore[import-untyped]
|
|
172
|
+
|
|
173
|
+
spec = self._font_map.get(font, self._fallback_font)
|
|
174
|
+
if spec in self._cache:
|
|
175
|
+
return self._cache[spec]
|
|
176
|
+
|
|
177
|
+
if spec is None:
|
|
178
|
+
pil_font = ImageFont.load_default()
|
|
179
|
+
else:
|
|
180
|
+
pil_font = ImageFont.truetype(spec[0], spec[1])
|
|
181
|
+
|
|
182
|
+
self._cache[spec] = pil_font
|
|
183
|
+
return pil_font
|
|
184
|
+
|
|
185
|
+
def measure_segment(self, text: str, font: str) -> dict[str, float]:
|
|
186
|
+
"""Return ``{"width": <px>}`` via Pillow ``getlength()``."""
|
|
187
|
+
pil_font = self._get_font(font)
|
|
188
|
+
return {"width": float(pil_font.getlength(text))}
|
|
189
|
+
|
|
190
|
+
def clear_cache(self) -> None:
|
|
191
|
+
"""Discard cached Pillow font objects."""
|
|
192
|
+
self._cache.clear()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# SvgBoundsAdapter
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']([^"\']+)["\']')
|
|
200
|
+
_SVG_WIDTH_RE = re.compile(r"<svg[^>]*\bwidth\s*=\s*[\"']?([\d.]+)")
|
|
201
|
+
_SVG_HEIGHT_RE = re.compile(r"<svg[^>]*\bheight\s*=\s*[\"']?([\d.]+)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SvgBoundsAdapter:
|
|
205
|
+
"""SVG measurement adapter — extracts dimensions from ``viewBox`` or
|
|
206
|
+
explicit ``width``/``height`` attributes in the SVG string.
|
|
207
|
+
|
|
208
|
+
Pure arithmetic: parses the SVG string for dimension attributes.
|
|
209
|
+
No DOM required. Works in any Python environment.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
__slots__ = ()
|
|
213
|
+
|
|
214
|
+
def measure_svg(self, content: str) -> dict[str, float]:
|
|
215
|
+
"""Return ``{"width": <px>, "height": <px>}`` parsed from the SVG."""
|
|
216
|
+
# Try viewBox first: viewBox="minX minY width height"
|
|
217
|
+
m = _VIEWBOX_RE.search(content)
|
|
218
|
+
if m:
|
|
219
|
+
parts = re.split(r"[\s,]+", m.group(1).strip())
|
|
220
|
+
if len(parts) >= 4:
|
|
221
|
+
w = float(parts[2])
|
|
222
|
+
h = float(parts[3])
|
|
223
|
+
if math.isfinite(w) and math.isfinite(h) and w > 0 and h > 0:
|
|
224
|
+
return {"width": w, "height": h}
|
|
225
|
+
raise ValueError(
|
|
226
|
+
"SvgBoundsAdapter: viewBox width/height are missing, "
|
|
227
|
+
"non-finite, or not positive"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Fall back to explicit width/height attributes
|
|
231
|
+
wm = _SVG_WIDTH_RE.search(content)
|
|
232
|
+
hm = _SVG_HEIGHT_RE.search(content)
|
|
233
|
+
if wm and hm:
|
|
234
|
+
w = float(wm.group(1))
|
|
235
|
+
h = float(hm.group(1))
|
|
236
|
+
if math.isfinite(w) and math.isfinite(h) and w > 0 and h > 0:
|
|
237
|
+
return {"width": w, "height": h}
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"SvgBoundsAdapter: svg width/height attributes are non-finite or not positive"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"SvgBoundsAdapter: cannot determine dimensions — "
|
|
244
|
+
"SVG has no viewBox or width/height attributes"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# ImageSizeAdapter
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ImageSizeAdapter:
|
|
254
|
+
"""Image measurement adapter — returns pre-registered dimensions by src key.
|
|
255
|
+
|
|
256
|
+
Sync-only: dimensions must be provided upfront via the ``sizes`` dict.
|
|
257
|
+
No I/O, no polling, no async.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
sizes:
|
|
262
|
+
``{src: {"width": <px>, "height": <px>}}`` mapping image sources to
|
|
263
|
+
their natural dimensions.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
__slots__ = ("_sizes",)
|
|
267
|
+
|
|
268
|
+
def __init__(self, sizes: dict[str, dict[str, float]]) -> None:
|
|
269
|
+
self._sizes = dict(sizes)
|
|
270
|
+
|
|
271
|
+
def measure_image(self, src: str) -> dict[str, float]:
|
|
272
|
+
"""Return ``{"width": <px>, "height": <px>}`` for a registered src."""
|
|
273
|
+
dims = self._sizes.get(src)
|
|
274
|
+
if dims is None:
|
|
275
|
+
raise KeyError(f"ImageSizeAdapter: no dimensions registered for {src!r}")
|
|
276
|
+
return dict(dims)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Reactive multi-content block layout engine (roadmap §7.1 — mixed content).
|
|
2
|
+
|
|
3
|
+
Extends the text-only ``reactive_layout`` with support for image and SVG blocks.
|
|
4
|
+
Pure-arithmetic layout over measured child sizes — no DOM, no async.
|
|
5
|
+
|
|
6
|
+
Graph shape::
|
|
7
|
+
|
|
8
|
+
Graph("reactive-block-layout")
|
|
9
|
+
├── state("blocks") — ContentBlock list input
|
|
10
|
+
├── state("max-width") — container constraint
|
|
11
|
+
├── state("gap") — vertical gap between blocks (px)
|
|
12
|
+
├── derived("measured-blocks") — blocks + max-width → MeasuredBlock list
|
|
13
|
+
├── derived("block-flow") — measured-blocks + gap → PositionedBlock list
|
|
14
|
+
├── derived("total-height") — block-flow → float
|
|
15
|
+
└── meta: { block-count, layout-time-ns }
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from graphrefly.core.clock import monotonic_ns
|
|
24
|
+
from graphrefly.core.protocol import MessageType, emit_with_batch
|
|
25
|
+
from graphrefly.core.sugar import derived, state
|
|
26
|
+
from graphrefly.graph.graph import Graph
|
|
27
|
+
from graphrefly.patterns.reactive_layout.reactive_layout import (
|
|
28
|
+
CharPosition,
|
|
29
|
+
LineBreaksResult,
|
|
30
|
+
MeasurementAdapter,
|
|
31
|
+
PreparedSegment,
|
|
32
|
+
analyze_and_measure,
|
|
33
|
+
compute_char_positions,
|
|
34
|
+
compute_line_breaks,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from graphrefly.core.node import NodeImpl
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Adapter protocols
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class SvgMeasurer(Protocol):
|
|
47
|
+
"""Pluggable measurement backend for SVG content."""
|
|
48
|
+
|
|
49
|
+
def measure_svg(self, content: str) -> dict[str, float]:
|
|
50
|
+
"""Return ``{"width": <px>, "height": <px>}``."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class ImageMeasurer(Protocol):
|
|
56
|
+
"""Pluggable measurement backend for image content."""
|
|
57
|
+
|
|
58
|
+
def measure_image(self, src: str) -> dict[str, float]:
|
|
59
|
+
"""Return ``{"width": <px>, "height": <px>}``."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Content block types
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class TextBlock:
|
|
70
|
+
"""A text content block."""
|
|
71
|
+
|
|
72
|
+
type: str = field(default="text", init=False)
|
|
73
|
+
text: str = ""
|
|
74
|
+
font: str | None = None
|
|
75
|
+
line_height: float | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ImageBlock:
|
|
80
|
+
"""An image content block."""
|
|
81
|
+
|
|
82
|
+
type: str = field(default="image", init=False)
|
|
83
|
+
src: str = ""
|
|
84
|
+
natural_width: float | None = None
|
|
85
|
+
natural_height: float | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SvgBlock:
|
|
90
|
+
"""An SVG content block."""
|
|
91
|
+
|
|
92
|
+
type: str = field(default="svg", init=False)
|
|
93
|
+
content: str = ""
|
|
94
|
+
view_box: tuple[float, float] | None = None # (width, height)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
ContentBlock = TextBlock | ImageBlock | SvgBlock
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Measured / positioned block types
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class MeasuredBlock:
|
|
107
|
+
"""A block after measurement — knows its natural dimensions.
|
|
108
|
+
|
|
109
|
+
**Equality note:** The reactive ``measured-blocks`` node uses dimension-only equality
|
|
110
|
+
(``type``, ``width``, ``height``, ``index``). Inner text layout data
|
|
111
|
+
(``text_segments``, ``text_line_breaks``, ``text_char_positions``) is NOT compared
|
|
112
|
+
for change detection. If you need text-level reactivity, use ``reactive_layout()``
|
|
113
|
+
directly per text block.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
index: int
|
|
117
|
+
type: str
|
|
118
|
+
width: float
|
|
119
|
+
height: float
|
|
120
|
+
text_segments: list[PreparedSegment] | None = None
|
|
121
|
+
text_line_breaks: LineBreaksResult | None = None
|
|
122
|
+
text_char_positions: list[CharPosition] | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class PositionedBlock(MeasuredBlock):
|
|
127
|
+
"""A block after flow — positioned in the container."""
|
|
128
|
+
|
|
129
|
+
x: float = 0.0
|
|
130
|
+
y: float = 0.0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class BlockAdapters:
|
|
135
|
+
"""Adapters map for ``reactive_block_layout``."""
|
|
136
|
+
|
|
137
|
+
text: MeasurementAdapter
|
|
138
|
+
svg: SvgMeasurer | None = None
|
|
139
|
+
image: ImageMeasurer | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class ReactiveBlockLayoutBundle:
|
|
144
|
+
"""Result of the reactive block layout graph factory."""
|
|
145
|
+
|
|
146
|
+
graph: Graph
|
|
147
|
+
set_blocks: Any # (list[ContentBlock]) -> None
|
|
148
|
+
set_max_width: Any # (float) -> None
|
|
149
|
+
set_gap: Any # (float) -> None
|
|
150
|
+
measured_blocks: NodeImpl[list[MeasuredBlock]]
|
|
151
|
+
block_flow: NodeImpl[list[PositionedBlock]]
|
|
152
|
+
total_height: NodeImpl[float]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Block measurement (pure functions)
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def measure_block(
|
|
161
|
+
block: ContentBlock,
|
|
162
|
+
max_width: float,
|
|
163
|
+
adapters: BlockAdapters,
|
|
164
|
+
measure_cache: dict[str, dict[str, float]],
|
|
165
|
+
default_font: str,
|
|
166
|
+
default_line_height: float,
|
|
167
|
+
index: int,
|
|
168
|
+
) -> MeasuredBlock:
|
|
169
|
+
"""Measure a single content block, returning natural dimensions."""
|
|
170
|
+
if isinstance(block, TextBlock):
|
|
171
|
+
font = block.font if block.font is not None else default_font
|
|
172
|
+
line_height = block.line_height if block.line_height is not None else default_line_height
|
|
173
|
+
segments = analyze_and_measure(block.text, font, adapters.text, measure_cache)
|
|
174
|
+
line_breaks = compute_line_breaks(segments, max_width, adapters.text, font, measure_cache)
|
|
175
|
+
char_positions = compute_char_positions(line_breaks, segments, line_height)
|
|
176
|
+
height = line_breaks.line_count * line_height
|
|
177
|
+
width = 0.0
|
|
178
|
+
for line in line_breaks.lines:
|
|
179
|
+
if line.width > width:
|
|
180
|
+
width = line.width
|
|
181
|
+
return MeasuredBlock(
|
|
182
|
+
index=index,
|
|
183
|
+
type="text",
|
|
184
|
+
width=min(width, max_width),
|
|
185
|
+
height=height,
|
|
186
|
+
text_segments=segments,
|
|
187
|
+
text_line_breaks=line_breaks,
|
|
188
|
+
text_char_positions=char_positions,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if isinstance(block, ImageBlock):
|
|
192
|
+
if block.natural_width is not None and block.natural_height is not None:
|
|
193
|
+
w, h = block.natural_width, block.natural_height
|
|
194
|
+
elif adapters.image is not None:
|
|
195
|
+
dims = adapters.image.measure_image(block.src)
|
|
196
|
+
w, h = dims["width"], dims["height"]
|
|
197
|
+
else:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Image block at index {index} has no natural_width/natural_height "
|
|
200
|
+
f"and no ImageMeasurer adapter"
|
|
201
|
+
)
|
|
202
|
+
if w > max_width:
|
|
203
|
+
h = h * max_width / w
|
|
204
|
+
w = max_width
|
|
205
|
+
return MeasuredBlock(index=index, type="image", width=w, height=h)
|
|
206
|
+
|
|
207
|
+
if isinstance(block, SvgBlock):
|
|
208
|
+
if block.view_box is not None:
|
|
209
|
+
w, h = block.view_box
|
|
210
|
+
elif adapters.svg is not None:
|
|
211
|
+
dims = adapters.svg.measure_svg(block.content)
|
|
212
|
+
w, h = dims["width"], dims["height"]
|
|
213
|
+
else:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"SVG block at index {index} has no view_box and no SvgMeasurer adapter"
|
|
216
|
+
)
|
|
217
|
+
if w > max_width:
|
|
218
|
+
h = h * max_width / w
|
|
219
|
+
w = max_width
|
|
220
|
+
return MeasuredBlock(index=index, type="svg", width=w, height=h)
|
|
221
|
+
|
|
222
|
+
raise TypeError(f"Unknown block type: {type(block)}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def measure_blocks(
|
|
226
|
+
blocks: list[ContentBlock],
|
|
227
|
+
max_width: float,
|
|
228
|
+
adapters: BlockAdapters,
|
|
229
|
+
measure_cache: dict[str, dict[str, float]],
|
|
230
|
+
default_font: str,
|
|
231
|
+
default_line_height: float,
|
|
232
|
+
) -> list[MeasuredBlock]:
|
|
233
|
+
"""Measure all blocks in a content array."""
|
|
234
|
+
return [
|
|
235
|
+
measure_block(
|
|
236
|
+
block,
|
|
237
|
+
max_width,
|
|
238
|
+
adapters,
|
|
239
|
+
measure_cache,
|
|
240
|
+
default_font,
|
|
241
|
+
default_line_height,
|
|
242
|
+
i,
|
|
243
|
+
)
|
|
244
|
+
for i, block in enumerate(blocks)
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Block flow (pure function)
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def compute_block_flow(
|
|
254
|
+
measured: list[MeasuredBlock],
|
|
255
|
+
gap: float,
|
|
256
|
+
) -> list[PositionedBlock]:
|
|
257
|
+
"""Vertical stacking flow: blocks top-to-bottom, left-aligned, separated by gap."""
|
|
258
|
+
result: list[PositionedBlock] = []
|
|
259
|
+
y = 0.0
|
|
260
|
+
for i, m in enumerate(measured):
|
|
261
|
+
result.append(
|
|
262
|
+
PositionedBlock(
|
|
263
|
+
index=m.index,
|
|
264
|
+
type=m.type,
|
|
265
|
+
width=m.width,
|
|
266
|
+
height=m.height,
|
|
267
|
+
text_segments=m.text_segments,
|
|
268
|
+
text_line_breaks=m.text_line_breaks,
|
|
269
|
+
text_char_positions=m.text_char_positions,
|
|
270
|
+
x=0.0,
|
|
271
|
+
y=y,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
y += m.height + (gap if i < len(measured) - 1 else 0)
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def compute_total_height(flow: list[PositionedBlock]) -> float:
|
|
279
|
+
"""Compute total height from positioned blocks."""
|
|
280
|
+
if not flow:
|
|
281
|
+
return 0.0
|
|
282
|
+
last = flow[-1]
|
|
283
|
+
return last.y + last.height
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Reactive graph factory
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def reactive_block_layout(
|
|
292
|
+
adapters: BlockAdapters,
|
|
293
|
+
*,
|
|
294
|
+
name: str = "reactive-block-layout",
|
|
295
|
+
blocks: list[ContentBlock] | None = None,
|
|
296
|
+
max_width: float = 800,
|
|
297
|
+
gap: float = 0,
|
|
298
|
+
default_font: str = "16px sans-serif",
|
|
299
|
+
default_line_height: float = 20,
|
|
300
|
+
) -> ReactiveBlockLayoutBundle:
|
|
301
|
+
"""Create a reactive block layout graph for mixed content.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
adapters:
|
|
306
|
+
Block measurement adapters (text required, svg/image optional).
|
|
307
|
+
name:
|
|
308
|
+
Graph name.
|
|
309
|
+
blocks:
|
|
310
|
+
Initial content blocks.
|
|
311
|
+
max_width:
|
|
312
|
+
Initial max width constraint (px). Values ``< 0`` are clamped to ``0``.
|
|
313
|
+
gap:
|
|
314
|
+
Vertical gap between blocks (px).
|
|
315
|
+
default_font:
|
|
316
|
+
Default CSS font string for text blocks without explicit font.
|
|
317
|
+
default_line_height:
|
|
318
|
+
Default line height for text blocks without explicit line_height.
|
|
319
|
+
"""
|
|
320
|
+
g = Graph(name)
|
|
321
|
+
measure_cache: dict[str, dict[str, float]] = {}
|
|
322
|
+
|
|
323
|
+
# --- State nodes ---
|
|
324
|
+
blocks_node: NodeImpl[list[ContentBlock]] = state(blocks or [], name="blocks")
|
|
325
|
+
max_width_node: NodeImpl[float] = state(max(0.0, max_width), name="max-width")
|
|
326
|
+
gap_node: NodeImpl[float] = state(gap, name="gap")
|
|
327
|
+
|
|
328
|
+
# --- Derived: measured-blocks ---
|
|
329
|
+
def _invalidate_cache(msg: tuple[Any, ...], _dep_index: int, _actions: Any) -> bool:
|
|
330
|
+
if msg[0] == MessageType.INVALIDATE or msg[0] == MessageType.TEARDOWN:
|
|
331
|
+
# Local side-effect only; return False so default dispatch
|
|
332
|
+
# still propagates the message (TEARDOWN → meta/downstream).
|
|
333
|
+
measure_cache.clear()
|
|
334
|
+
clear_fn = getattr(adapters.text, "clear_cache", None)
|
|
335
|
+
if callable(clear_fn):
|
|
336
|
+
clear_fn()
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
def _compute_measured(deps: list[Any], _actions: Any) -> list[MeasuredBlock]:
|
|
340
|
+
blks: list[ContentBlock] = deps[0]
|
|
341
|
+
mw: float = deps[1]
|
|
342
|
+
t0 = monotonic_ns()
|
|
343
|
+
result = measure_blocks(
|
|
344
|
+
blks, mw, adapters, measure_cache, default_font, default_line_height
|
|
345
|
+
)
|
|
346
|
+
elapsed = monotonic_ns() - t0
|
|
347
|
+
|
|
348
|
+
meta = measured_blocks_node.meta
|
|
349
|
+
if meta:
|
|
350
|
+
bc = meta.get("block-count")
|
|
351
|
+
if bc is not None:
|
|
352
|
+
emit_with_batch(bc.down, [(MessageType.DATA, len(result))], phase=3)
|
|
353
|
+
lt = meta.get("layout-time-ns")
|
|
354
|
+
if lt is not None:
|
|
355
|
+
emit_with_batch(lt.down, [(MessageType.DATA, elapsed)], phase=3)
|
|
356
|
+
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
def _measured_equals(a: list[MeasuredBlock] | None, b: list[MeasuredBlock] | None) -> bool:
|
|
360
|
+
if a is None or b is None:
|
|
361
|
+
return a is b
|
|
362
|
+
if len(a) != len(b):
|
|
363
|
+
return False
|
|
364
|
+
return all(
|
|
365
|
+
ma.type == mb.type
|
|
366
|
+
and ma.width == mb.width
|
|
367
|
+
and ma.height == mb.height
|
|
368
|
+
and ma.index == mb.index
|
|
369
|
+
for ma, mb in zip(a, b, strict=True)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
measured_blocks_node: NodeImpl[list[MeasuredBlock]] = derived(
|
|
373
|
+
[blocks_node, max_width_node],
|
|
374
|
+
_compute_measured,
|
|
375
|
+
name="measured-blocks",
|
|
376
|
+
meta={"block-count": 0, "layout-time-ns": 0},
|
|
377
|
+
on_message=_invalidate_cache,
|
|
378
|
+
equals=_measured_equals,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# --- Derived: block-flow ---
|
|
382
|
+
def _compute_flow(deps: list[Any], _actions: Any) -> list[PositionedBlock]:
|
|
383
|
+
meas: list[MeasuredBlock] = deps[0]
|
|
384
|
+
g_val: float = deps[1]
|
|
385
|
+
return compute_block_flow(meas, g_val)
|
|
386
|
+
|
|
387
|
+
def _flow_equals(a: list[PositionedBlock] | None, b: list[PositionedBlock] | None) -> bool:
|
|
388
|
+
if a is None or b is None:
|
|
389
|
+
return a is b
|
|
390
|
+
if len(a) != len(b):
|
|
391
|
+
return False
|
|
392
|
+
return all(
|
|
393
|
+
pa.x == pb.x and pa.y == pb.y and pa.width == pb.width and pa.height == pb.height
|
|
394
|
+
for pa, pb in zip(a, b, strict=True)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
block_flow_node: NodeImpl[list[PositionedBlock]] = derived(
|
|
398
|
+
[measured_blocks_node, gap_node],
|
|
399
|
+
_compute_flow,
|
|
400
|
+
name="block-flow",
|
|
401
|
+
equals=_flow_equals,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# --- Derived: total-height ---
|
|
405
|
+
total_height_node: NodeImpl[float] = derived(
|
|
406
|
+
[block_flow_node],
|
|
407
|
+
lambda deps, _a: compute_total_height(deps[0]),
|
|
408
|
+
name="total-height",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# --- Register in graph ---
|
|
412
|
+
g.add("blocks", blocks_node)
|
|
413
|
+
g.add("max-width", max_width_node)
|
|
414
|
+
g.add("gap", gap_node)
|
|
415
|
+
g.add("measured-blocks", measured_blocks_node)
|
|
416
|
+
g.add("block-flow", block_flow_node)
|
|
417
|
+
g.add("total-height", total_height_node)
|
|
418
|
+
|
|
419
|
+
# --- Edges (for describe() visibility) ---
|
|
420
|
+
g.connect("blocks", "measured-blocks")
|
|
421
|
+
g.connect("max-width", "measured-blocks")
|
|
422
|
+
g.connect("measured-blocks", "block-flow")
|
|
423
|
+
g.connect("gap", "block-flow")
|
|
424
|
+
g.connect("block-flow", "total-height")
|
|
425
|
+
|
|
426
|
+
return ReactiveBlockLayoutBundle(
|
|
427
|
+
graph=g,
|
|
428
|
+
set_blocks=lambda b: g.set("blocks", b),
|
|
429
|
+
set_max_width=lambda mw: g.set("max-width", max(0.0, mw)),
|
|
430
|
+
set_gap=lambda gv: g.set("gap", gv),
|
|
431
|
+
measured_blocks=measured_blocks_node,
|
|
432
|
+
block_flow=block_flow_node,
|
|
433
|
+
total_height=total_height_node,
|
|
434
|
+
)
|