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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. 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
+ )