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,943 @@
1
+ """Reactive text layout engine (roadmap §7.1 — Pretext parity).
2
+
3
+ Pure-arithmetic text measurement and line breaking without DOM thrashing.
4
+ Inspired by `Pretext <https://github.com/chenglou/pretext>`_, rebuilt as a
5
+ GraphReFly graph — inspectable via ``describe()``, snapshotable, debuggable.
6
+
7
+ Two-tier DX:
8
+
9
+ - ``reactive_layout(adapter, *, text=..., font=..., ...)`` — convenience factory
10
+ - :class:`MeasurementAdapter` — pluggable backends (``measure_segment``; optional ``clear_cache``)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import unicodedata
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
20
+
21
+ from graphrefly.core.clock import monotonic_ns
22
+ from graphrefly.core.protocol import MessageType, emit_with_batch
23
+ from graphrefly.core.sugar import derived, state
24
+ from graphrefly.graph.graph import Graph
25
+
26
+ if TYPE_CHECKING:
27
+ from graphrefly.core.node import NodeImpl
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Types
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ @runtime_checkable
35
+ class MeasurementAdapter(Protocol):
36
+ """Pluggable measurement backend.
37
+
38
+ Implementations may omit ``clear_cache``; the layout engine treats it as an
39
+ optional no-op hook.
40
+ """
41
+
42
+ def measure_segment(self, text: str, font: str) -> dict[str, float]:
43
+ """Return ``{"width": <px>}`` for *text* rendered in *font*."""
44
+ ...
45
+
46
+ def clear_cache(self) -> None:
47
+ """Optional hook to clear internal cached measurement state."""
48
+ ...
49
+
50
+
51
+ class SegmentBreakKind(StrEnum):
52
+ """Break kind for each segment (ported from Pretext analysis.ts)."""
53
+
54
+ TEXT = "text"
55
+ SPACE = "space"
56
+ ZERO_WIDTH_BREAK = "zero-width-break"
57
+ SOFT_HYPHEN = "soft-hyphen"
58
+ HARD_BREAK = "hard-break"
59
+
60
+
61
+ class PreparedSegment:
62
+ """A measured text segment ready for line breaking."""
63
+
64
+ __slots__ = ("text", "width", "kind", "grapheme_widths")
65
+
66
+ def __init__(
67
+ self,
68
+ text: str,
69
+ width: float,
70
+ kind: SegmentBreakKind,
71
+ grapheme_widths: list[float] | None = None,
72
+ ) -> None:
73
+ self.text = text
74
+ self.width = width
75
+ self.kind = kind
76
+ self.grapheme_widths = grapheme_widths
77
+
78
+ def __eq__(self, other: object) -> bool:
79
+ if not isinstance(other, PreparedSegment):
80
+ return NotImplemented
81
+ return (
82
+ self.text == other.text
83
+ and self.width == other.width
84
+ and self.kind == other.kind
85
+ and self.grapheme_widths == other.grapheme_widths
86
+ )
87
+
88
+ def __repr__(self) -> str:
89
+ return (
90
+ f"PreparedSegment({self.text!r}, {self.width}, {self.kind!r},"
91
+ f" grapheme_widths={self.grapheme_widths})"
92
+ )
93
+
94
+
95
+ class LayoutLine:
96
+ """A laid-out line with start/end cursors."""
97
+
98
+ __slots__ = (
99
+ "text",
100
+ "width",
101
+ "start_segment",
102
+ "start_grapheme",
103
+ "end_segment",
104
+ "end_grapheme",
105
+ )
106
+
107
+ def __init__(
108
+ self,
109
+ text: str,
110
+ width: float,
111
+ start_segment: int,
112
+ start_grapheme: int,
113
+ end_segment: int,
114
+ end_grapheme: int,
115
+ ) -> None:
116
+ self.text = text
117
+ self.width = width
118
+ self.start_segment = start_segment
119
+ self.start_grapheme = start_grapheme
120
+ self.end_segment = end_segment
121
+ self.end_grapheme = end_grapheme
122
+
123
+ def __eq__(self, other: object) -> bool:
124
+ if not isinstance(other, LayoutLine):
125
+ return NotImplemented
126
+ return (
127
+ self.text == other.text
128
+ and self.width == other.width
129
+ and self.start_segment == other.start_segment
130
+ and self.start_grapheme == other.start_grapheme
131
+ and self.end_segment == other.end_segment
132
+ and self.end_grapheme == other.end_grapheme
133
+ )
134
+
135
+ def __repr__(self) -> str:
136
+ return (
137
+ f"LayoutLine({self.text!r}, width={self.width},"
138
+ f" [{self.start_segment}:{self.start_grapheme}"
139
+ f" → {self.end_segment}:{self.end_grapheme}])"
140
+ )
141
+
142
+
143
+ class CharPosition:
144
+ """Per-character position for hit testing."""
145
+
146
+ __slots__ = ("x", "y", "width", "height", "line")
147
+
148
+ def __init__(self, x: float, y: float, width: float, height: float, line: int) -> None:
149
+ self.x = x
150
+ self.y = y
151
+ self.width = width
152
+ self.height = height
153
+ self.line = line
154
+
155
+ def __eq__(self, other: object) -> bool:
156
+ if not isinstance(other, CharPosition):
157
+ return NotImplemented
158
+ return (
159
+ self.x == other.x
160
+ and self.y == other.y
161
+ and self.width == other.width
162
+ and self.height == other.height
163
+ and self.line == other.line
164
+ )
165
+
166
+ def __repr__(self) -> str:
167
+ return (
168
+ f"CharPosition(x={self.x}, y={self.y},"
169
+ f" w={self.width}, h={self.height}, line={self.line})"
170
+ )
171
+
172
+
173
+ @dataclass
174
+ class LineBreaksResult:
175
+ """Full layout result from the line-breaks derived node."""
176
+
177
+ lines: list[LayoutLine]
178
+ line_count: int
179
+
180
+
181
+ @dataclass
182
+ class ReactiveLayoutBundle:
183
+ """Result of the reactive layout graph factory."""
184
+
185
+ graph: Graph
186
+ set_text: Any # (str) -> None
187
+ set_font: Any # (str) -> None
188
+ set_line_height: Any # (float) -> None
189
+ set_max_width: Any # (float) -> None
190
+ segments: NodeImpl[list[PreparedSegment]]
191
+ line_breaks: NodeImpl[LineBreaksResult]
192
+ height: NodeImpl[float]
193
+ char_positions: NodeImpl[list[CharPosition]]
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Text analysis (ported from Pretext analysis.ts — core subset)
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _is_cjk(s: str) -> bool:
202
+ """Return *True* if *s* contains any CJK codepoint."""
203
+ for ch in s:
204
+ c = ord(ch)
205
+ if (
206
+ (0x4E00 <= c <= 0x9FFF) # CJK Unified Ideographs
207
+ or (0x3400 <= c <= 0x4DBF) # CJK Extension A
208
+ or (0x3000 <= c <= 0x303F) # CJK Symbols and Punctuation
209
+ or (0x3040 <= c <= 0x309F) # Hiragana
210
+ or (0x30A0 <= c <= 0x30FF) # Katakana
211
+ or (0xAC00 <= c <= 0xD7AF) # Hangul
212
+ or (0xFF00 <= c <= 0xFFEF) # Fullwidth Forms
213
+ ):
214
+ return True
215
+ return False
216
+
217
+
218
+ # Kinsoku: characters that cannot start a line (CJK punctuation)
219
+ _KINSOKU_START: frozenset[str] = frozenset(
220
+ "\uff0c\uff0e\uff01\uff1a\uff1b\uff1f"
221
+ "\u3001\u3002\u30fb"
222
+ "\uff09\u3015\u3009\u300b\u300d\u300f\u3011"
223
+ )
224
+
225
+ # Left-sticky punctuation (merges into preceding segment)
226
+ _LEFT_STICKY_PUNCTUATION: frozenset[str] = frozenset('.,!?:;)]}\u201d\u2019\u00bb\u203a\u2026%"')
227
+
228
+ # Whitespace normalization regex (CSS white-space: normal)
229
+ _WS_RE = re.compile(r"[\t\n\r\f ]+")
230
+
231
+
232
+ def _normalize_whitespace(text: str) -> str:
233
+ """Normalize collapsible whitespace (CSS white-space: normal).
234
+
235
+ Matches ``graphrefly-ts`` ``normalizeWhitespace``: collapse ``[\\t\\n\\r\\f ]+``
236
+ to a single ASCII space, then remove at most one leading and one trailing
237
+ ASCII space (not full Unicode :meth:`str.strip`).
238
+ """
239
+ s = _WS_RE.sub(" ", text)
240
+ if s.startswith(" "):
241
+ s = s[1:]
242
+ if s.endswith(" "):
243
+ s = s[:-1]
244
+ return s
245
+
246
+
247
+ def _iter_graphemes(text: str) -> list[str]:
248
+ """Iterate grapheme clusters in *text*.
249
+
250
+ Uses a simple approach: iterate codepoints and merge combining marks
251
+ into the preceding base character. Sufficient for CJK, Latin, emoji
252
+ base cases covered by the TS test suite.
253
+ """
254
+ clusters: list[str] = []
255
+ current = ""
256
+ for ch in text:
257
+ cat = unicodedata.category(ch)
258
+ if cat.startswith("M") and current:
259
+ # Combining mark — append to current cluster
260
+ current += ch
261
+ else:
262
+ if current:
263
+ clusters.append(current)
264
+ current = ch
265
+ if current:
266
+ clusters.append(current)
267
+ return clusters
268
+
269
+
270
+ def _segment_text(
271
+ normalized: str,
272
+ ) -> list[tuple[list[str], list[bool], list[SegmentBreakKind]]]:
273
+ """Segment text using word-boundary splitting and classify break kinds.
274
+
275
+ Returns raw segmentation pieces before merging.
276
+ Mirrors TS ``segmentText()`` using ``Intl.Segmenter({granularity: "word"})``.
277
+ """
278
+ # Split into word-like and non-word-like pieces using Unicode word boundaries.
279
+ # Python's re \b{} extended boundary is not available, so we use a simpler
280
+ # approach: split on runs of word characters vs non-word characters.
281
+ pieces: list[tuple[list[str], list[bool], list[SegmentBreakKind]]] = []
282
+
283
+ # Use regex to split into alternating word/non-word tokens
284
+ tokens = re.findall(r"\w+|[^\w]+", normalized, re.UNICODE)
285
+
286
+ for token in tokens:
287
+ is_word_like = bool(re.match(r"\w", token, re.UNICODE))
288
+
289
+ texts: list[str] = []
290
+ word_likes: list[bool] = []
291
+ kinds: list[SegmentBreakKind] = []
292
+
293
+ current_text = ""
294
+ current_kind: SegmentBreakKind | None = None
295
+
296
+ for ch in token:
297
+ if ch == " ":
298
+ kind = SegmentBreakKind.SPACE
299
+ elif ch == "\u200b":
300
+ kind = SegmentBreakKind.ZERO_WIDTH_BREAK
301
+ elif ch == "\u00ad":
302
+ kind = SegmentBreakKind.SOFT_HYPHEN
303
+ elif ch == "\n":
304
+ kind = SegmentBreakKind.HARD_BREAK
305
+ else:
306
+ kind = SegmentBreakKind.TEXT
307
+
308
+ if current_kind is not None and kind == current_kind:
309
+ current_text += ch
310
+ else:
311
+ if current_kind is not None:
312
+ texts.append(current_text)
313
+ word_likes.append(current_kind == SegmentBreakKind.TEXT and is_word_like)
314
+ kinds.append(current_kind)
315
+ current_text = ch
316
+ current_kind = kind
317
+
318
+ if current_kind is not None:
319
+ texts.append(current_text)
320
+ word_likes.append(current_kind == SegmentBreakKind.TEXT and is_word_like)
321
+ kinds.append(current_kind)
322
+
323
+ pieces.append((texts, word_likes, kinds))
324
+
325
+ return pieces
326
+
327
+
328
+ def analyze_and_measure(
329
+ text: str,
330
+ font: str,
331
+ adapter: MeasurementAdapter,
332
+ cache: dict[str, dict[str, float]],
333
+ measure_stats: dict[str, int] | None = None,
334
+ ) -> list[PreparedSegment]:
335
+ """Analyze text, merge segments, measure widths, return prepared segments.
336
+
337
+ Port of TS ``analyzeAndMeasure()``.
338
+
339
+ Args:
340
+ text: Raw input text.
341
+ font: CSS font string (passed to adapter).
342
+ adapter: Measurement backend.
343
+ cache: Shared ``{font: {segment: width}}`` measurement cache.
344
+ measure_stats: If provided, must be a dict with ``hits`` and ``misses`` keys
345
+ (integers); incremented for each cache hit/miss in ``measure_segment``.
346
+
347
+ Returns:
348
+ List of :class:`PreparedSegment` ready for line breaking.
349
+ """
350
+ normalized = _normalize_whitespace(text)
351
+ if not normalized:
352
+ return []
353
+
354
+ pieces = _segment_text(normalized)
355
+
356
+ # Flatten pieces into a single segment list
357
+ raw_texts: list[str] = []
358
+ raw_kinds: list[SegmentBreakKind] = []
359
+ raw_word_like: list[bool] = []
360
+
361
+ for texts, word_likes, kinds in pieces:
362
+ for i in range(len(texts)):
363
+ raw_texts.append(texts[i])
364
+ raw_kinds.append(kinds[i])
365
+ raw_word_like.append(word_likes[i])
366
+
367
+ # Merge: left-sticky punctuation and kinsoku-start into preceding text segment
368
+ merged_texts: list[str] = []
369
+ merged_kinds: list[SegmentBreakKind] = []
370
+ merged_word_like: list[bool] = []
371
+
372
+ for i in range(len(raw_texts)):
373
+ t = raw_texts[i]
374
+ k = raw_kinds[i]
375
+ wl = raw_word_like[i]
376
+
377
+ # Merge left-sticky punctuation into preceding text
378
+ if (
379
+ k == SegmentBreakKind.TEXT
380
+ and not wl
381
+ and merged_texts
382
+ and merged_kinds[-1] == SegmentBreakKind.TEXT
383
+ ):
384
+ is_sticky = len(t) == 1 and (t in _LEFT_STICKY_PUNCTUATION or t in _KINSOKU_START)
385
+ if is_sticky:
386
+ merged_texts[-1] += t
387
+ continue
388
+
389
+ # Merge hyphen after word into preceding text ("well-known" stays together)
390
+ if (
391
+ t == "-"
392
+ and merged_texts
393
+ and merged_kinds[-1] == SegmentBreakKind.TEXT
394
+ and merged_word_like[-1]
395
+ ):
396
+ merged_texts[-1] += t
397
+ continue
398
+
399
+ merged_texts.append(t)
400
+ merged_kinds.append(k)
401
+ merged_word_like.append(wl)
402
+
403
+ # Get or create font-specific cache
404
+ font_cache = cache.get(font)
405
+ if font_cache is None:
406
+ font_cache = {}
407
+ cache[font] = font_cache
408
+
409
+ def measure_cached(seg: str) -> float:
410
+ w = font_cache.get(seg)
411
+ if w is None:
412
+ if measure_stats is not None:
413
+ measure_stats["misses"] += 1
414
+ w = adapter.measure_segment(seg, font)["width"]
415
+ font_cache[seg] = w
416
+ elif measure_stats is not None:
417
+ measure_stats["hits"] += 1
418
+ return w
419
+
420
+ # Build final prepared segments, splitting CJK into per-grapheme
421
+ segments: list[PreparedSegment] = []
422
+
423
+ for i in range(len(merged_texts)):
424
+ t = merged_texts[i]
425
+ k = merged_kinds[i]
426
+
427
+ if k != SegmentBreakKind.TEXT:
428
+ # Non-text segments: space, hard-break, soft-hyphen, zero-width-break
429
+ width = measure_cached(" ") * len(t) if k == SegmentBreakKind.SPACE else 0.0
430
+ segments.append(PreparedSegment(t, width, k, None))
431
+ continue
432
+
433
+ # CJK text: split into per-grapheme segments for line breaking
434
+ if _is_cjk(t):
435
+ graphemes = _iter_graphemes(t)
436
+ unit_text = ""
437
+ for grapheme in graphemes:
438
+ # Kinsoku: line-start-prohibited chars stick to preceding unit
439
+ if unit_text and grapheme in _KINSOKU_START:
440
+ unit_text += grapheme
441
+ continue
442
+
443
+ if unit_text:
444
+ w = measure_cached(unit_text)
445
+ segments.append(PreparedSegment(unit_text, w, SegmentBreakKind.TEXT, None))
446
+ unit_text = grapheme
447
+
448
+ if unit_text:
449
+ w = measure_cached(unit_text)
450
+ segments.append(PreparedSegment(unit_text, w, SegmentBreakKind.TEXT, None))
451
+ continue
452
+
453
+ # Non-CJK text: measure whole segment, pre-compute grapheme widths for break-word
454
+ w = measure_cached(t)
455
+ grapheme_widths: list[float] | None = None
456
+
457
+ if merged_word_like[i] and len(t) > 1:
458
+ graphemes = _iter_graphemes(t)
459
+ if len(graphemes) > 1:
460
+ grapheme_widths = [measure_cached(g) for g in graphemes]
461
+
462
+ segments.append(PreparedSegment(t, w, SegmentBreakKind.TEXT, grapheme_widths))
463
+
464
+ return segments
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Line breaking (greedy, ported from Pretext line-break.ts — core subset)
469
+ # ---------------------------------------------------------------------------
470
+
471
+
472
+ def compute_line_breaks(
473
+ segments: list[PreparedSegment],
474
+ max_width: float,
475
+ adapter: MeasurementAdapter,
476
+ font: str,
477
+ cache: dict[str, dict[str, float]],
478
+ ) -> LineBreaksResult:
479
+ """Greedy line-breaking algorithm.
480
+
481
+ Walks segments left to right, accumulating width. Breaks when a segment
482
+ would overflow *max_width*. Supports:
483
+
484
+ - Trailing space hang (spaces don't trigger breaks)
485
+ - ``overflow-wrap: break-word`` via grapheme widths
486
+ - Soft hyphens (break opportunity, adds visible hyphen width)
487
+ - Hard breaks (forced newline)
488
+ """
489
+ if not segments:
490
+ return LineBreaksResult(lines=[], line_count=0)
491
+
492
+ lines: list[LayoutLine] = []
493
+ line_w = 0.0
494
+ has_content = False
495
+ line_start_seg = 0
496
+ line_start_grapheme = 0
497
+ line_end_seg = 0
498
+ line_end_grapheme = 0
499
+ pending_break_seg = -1
500
+ pending_break_width = 0.0
501
+
502
+ # Measure hyphen for soft-hyphen support
503
+ font_cache = cache.get(font)
504
+ if font_cache is None:
505
+ font_cache = {}
506
+ cache[font] = font_cache
507
+ hyphen_width = font_cache.get("-")
508
+ if hyphen_width is None:
509
+ hyphen_width = adapter.measure_segment("-", font)["width"]
510
+ font_cache["-"] = hyphen_width
511
+
512
+ # --- Nested helpers (closures over mutable state) ---
513
+
514
+ def emit_line(
515
+ end_seg: int = -1,
516
+ end_grapheme: int = -1,
517
+ width: float = -1.0,
518
+ ) -> None:
519
+ nonlocal line_w, has_content, pending_break_seg, pending_break_width
520
+ nonlocal line_start_seg, line_start_grapheme
521
+
522
+ es = end_seg if end_seg >= 0 else line_end_seg
523
+ eg = end_grapheme if end_grapheme >= 0 else line_end_grapheme
524
+ w = width if width >= 0 else line_w
525
+
526
+ # Build line text
527
+ text_parts: list[str] = []
528
+ for si in range(line_start_seg, es):
529
+ seg = segments[si]
530
+ if seg.kind in (SegmentBreakKind.SOFT_HYPHEN, SegmentBreakKind.HARD_BREAK):
531
+ continue
532
+ if si == line_start_seg and line_start_grapheme > 0 and seg.grapheme_widths:
533
+ graphemes = _iter_graphemes(seg.text)
534
+ text_parts.append("".join(graphemes[line_start_grapheme:]))
535
+ else:
536
+ text_parts.append(seg.text)
537
+
538
+ # Handle partial end segment
539
+ if eg > 0 and es < len(segments):
540
+ seg = segments[es]
541
+ graphemes = _iter_graphemes(seg.text)
542
+ start_g = line_start_grapheme if line_start_seg == es else 0
543
+ text_parts.append("".join(graphemes[start_g:eg]))
544
+
545
+ # Add visible hyphen if line ends at soft-hyphen
546
+ if (
547
+ es > 0
548
+ and segments[es - 1].kind == SegmentBreakKind.SOFT_HYPHEN
549
+ and not (line_start_seg == es and line_start_grapheme > 0)
550
+ ):
551
+ text_parts.append("-")
552
+
553
+ line_text = "".join(text_parts)
554
+ lines.append(LayoutLine(line_text, w, line_start_seg, line_start_grapheme, es, eg))
555
+ line_w = 0.0
556
+ has_content = False
557
+ pending_break_seg = -1
558
+ pending_break_width = 0.0
559
+
560
+ def can_break_after(kind: SegmentBreakKind) -> bool:
561
+ return kind in (
562
+ SegmentBreakKind.SPACE,
563
+ SegmentBreakKind.ZERO_WIDTH_BREAK,
564
+ SegmentBreakKind.SOFT_HYPHEN,
565
+ )
566
+
567
+ def start_line(seg_idx: int, grapheme_idx: int, width: float) -> None:
568
+ nonlocal has_content, line_start_seg, line_start_grapheme
569
+ nonlocal line_end_seg, line_end_grapheme, line_w
570
+ has_content = True
571
+ line_start_seg = seg_idx
572
+ line_start_grapheme = grapheme_idx
573
+ line_end_seg = seg_idx + 1
574
+ line_end_grapheme = 0
575
+ line_w = width
576
+
577
+ def start_line_at_grapheme(seg_idx: int, grapheme_idx: int, width: float) -> None:
578
+ nonlocal has_content, line_start_seg, line_start_grapheme
579
+ nonlocal line_end_seg, line_end_grapheme, line_w
580
+ has_content = True
581
+ line_start_seg = seg_idx
582
+ line_start_grapheme = grapheme_idx
583
+ line_end_seg = seg_idx
584
+ line_end_grapheme = grapheme_idx + 1
585
+ line_w = width
586
+
587
+ def append_breakable_segment(seg_idx: int, start_g: int, g_widths: list[float]) -> None:
588
+ nonlocal has_content, line_w, line_end_seg, line_end_grapheme
589
+ for g in range(start_g, len(g_widths)):
590
+ gw = g_widths[g]
591
+ if not has_content:
592
+ start_line_at_grapheme(seg_idx, g, gw)
593
+ continue
594
+ if line_w + gw > max_width + 0.005:
595
+ emit_line()
596
+ start_line_at_grapheme(seg_idx, g, gw)
597
+ else:
598
+ line_w += gw
599
+ line_end_seg = seg_idx
600
+ line_end_grapheme = g + 1
601
+ # If we consumed the whole segment, advance end past it
602
+ if has_content and line_end_seg == seg_idx and line_end_grapheme == len(g_widths):
603
+ line_end_seg = seg_idx + 1
604
+ line_end_grapheme = 0
605
+
606
+ # --- Main loop ---
607
+ i = 0
608
+ while i < len(segments):
609
+ seg = segments[i]
610
+
611
+ # Hard break: emit current line, start fresh
612
+ if seg.kind == SegmentBreakKind.HARD_BREAK:
613
+ if has_content:
614
+ emit_line()
615
+ else:
616
+ lines.append(LayoutLine("", 0.0, i, 0, i, 0))
617
+ line_start_seg = i + 1
618
+ line_start_grapheme = 0
619
+ i += 1
620
+ continue
621
+
622
+ w = seg.width
623
+
624
+ if not has_content:
625
+ # First content on a new line
626
+ if w > max_width and seg.grapheme_widths:
627
+ append_breakable_segment(i, 0, seg.grapheme_widths)
628
+ else:
629
+ start_line(i, 0, w)
630
+ if can_break_after(seg.kind):
631
+ pending_break_seg = i + 1
632
+ pending_break_width = line_w - w if seg.kind == SegmentBreakKind.SPACE else line_w
633
+ i += 1
634
+ continue
635
+
636
+ new_w = line_w + w
637
+
638
+ if new_w > max_width + 0.005:
639
+ # Overflow
640
+ if can_break_after(seg.kind):
641
+ # Trailing space: hang past edge, then break
642
+ line_w += w
643
+ line_end_seg = i + 1
644
+ line_end_grapheme = 0
645
+ emit_line(
646
+ i + 1,
647
+ 0,
648
+ line_w - w if seg.kind == SegmentBreakKind.SPACE else line_w,
649
+ )
650
+ i += 1
651
+ continue
652
+
653
+ if pending_break_seg >= 0:
654
+ # Break at last break opportunity
655
+ emit_line(pending_break_seg, 0, pending_break_width)
656
+ # Don't advance i — re-process current segment on new line
657
+ continue
658
+
659
+ if w > max_width and seg.grapheme_widths:
660
+ # Break-word: split at grapheme level
661
+ emit_line()
662
+ append_breakable_segment(i, 0, seg.grapheme_widths)
663
+ i += 1
664
+ continue
665
+
666
+ # No break opportunity: force break before this segment
667
+ emit_line()
668
+ continue
669
+
670
+ # Fits on current line
671
+ line_w = new_w
672
+ line_end_seg = i + 1
673
+ line_end_grapheme = 0
674
+
675
+ if can_break_after(seg.kind):
676
+ pending_break_seg = i + 1
677
+ pending_break_width = line_w - w if seg.kind == SegmentBreakKind.SPACE else line_w
678
+
679
+ i += 1
680
+
681
+ if has_content:
682
+ emit_line()
683
+
684
+ return LineBreaksResult(lines=lines, line_count=len(lines))
685
+
686
+
687
+ # ---------------------------------------------------------------------------
688
+ # Character positions
689
+ # ---------------------------------------------------------------------------
690
+
691
+
692
+ def compute_char_positions(
693
+ line_breaks: LineBreaksResult,
694
+ segments: list[PreparedSegment],
695
+ line_height: float,
696
+ ) -> list[CharPosition]:
697
+ """Compute per-character x,y positions from line breaks and segments."""
698
+ positions: list[CharPosition] = []
699
+
700
+ for line_idx, line in enumerate(line_breaks.lines):
701
+ y = line_idx * line_height
702
+ x = 0.0
703
+
704
+ for si in range(line.start_segment, len(segments)):
705
+ seg = segments[si]
706
+ if seg.kind in (SegmentBreakKind.SOFT_HYPHEN, SegmentBreakKind.HARD_BREAK):
707
+ if si >= line.end_segment and line.end_grapheme == 0:
708
+ break
709
+ continue
710
+
711
+ graphemes = _iter_graphemes(seg.text)
712
+ if not graphemes:
713
+ continue # empty TEXT segment — skip (QA P4: avoid ZeroDivisionError)
714
+ start_g = line.start_grapheme if si == line.start_segment else 0
715
+
716
+ # Determine how many graphemes of this segment belong to this line
717
+ if si < line.end_segment:
718
+ end_g = len(graphemes)
719
+ elif si == line.end_segment and line.end_grapheme > 0:
720
+ end_g = line.end_grapheme
721
+ else:
722
+ break
723
+
724
+ for g in range(start_g, end_g):
725
+ g_width = (
726
+ seg.grapheme_widths[g] if seg.grapheme_widths else seg.width / len(graphemes)
727
+ )
728
+ positions.append(CharPosition(x, y, g_width, line_height, line_idx))
729
+ x += g_width
730
+
731
+ return positions
732
+
733
+
734
+ # ---------------------------------------------------------------------------
735
+ # Reactive graph factory
736
+ # ---------------------------------------------------------------------------
737
+
738
+
739
+ def reactive_layout(
740
+ adapter: MeasurementAdapter,
741
+ *,
742
+ name: str = "reactive-layout",
743
+ text: str = "",
744
+ font: str = "16px sans-serif",
745
+ line_height: float = 20.0,
746
+ max_width: float = 800.0,
747
+ ) -> ReactiveLayoutBundle:
748
+ """Create a reactive text layout graph.
749
+
750
+ .. code-block:: text
751
+
752
+ Graph("reactive-layout")
753
+ ├── state("text")
754
+ ├── state("font")
755
+ ├── state("line-height")
756
+ ├── state("max-width")
757
+ ├── derived("segments") — text + font → PreparedSegment[]
758
+ ├── derived("line-breaks") — segments + max-width → LineBreaksResult
759
+ ├── derived("height") — line-breaks → number
760
+ └── derived("char-positions") — line-breaks + segments → CharPosition[]
761
+
762
+ Args:
763
+ adapter: Measurement backend (required).
764
+ name: Graph name (default ``"reactive-layout"``).
765
+ text: Initial text content.
766
+ font: Initial CSS font string.
767
+ line_height: Initial line height in px.
768
+ max_width: Initial max width constraint in px. Values ``< 0`` are clamped to ``0``.
769
+
770
+ Returns:
771
+ :class:`ReactiveLayoutBundle` with graph, setters, and node references.
772
+ """
773
+ g = Graph(name)
774
+
775
+ # Shared measurement cache: {font: {segment: width}}
776
+ measure_cache: dict[str, dict[str, float]] = {}
777
+
778
+ def _invalidate_measure_cache(
779
+ msg: Any,
780
+ cache: dict[str, dict[str, float]],
781
+ adapter: MeasurementAdapter,
782
+ ) -> bool:
783
+ """Clear closure-held cache on INVALIDATE/TEARDOWN.
784
+
785
+ Returns False so default dispatch still propagates the message
786
+ (TEARDOWN → meta/downstream, INVALIDATE → clear _cached).
787
+ """
788
+ if msg[0] == MessageType.INVALIDATE or msg[0] == MessageType.TEARDOWN:
789
+ cache.clear()
790
+ clear_fn = getattr(adapter, "clear_cache", None)
791
+ if callable(clear_fn):
792
+ clear_fn()
793
+ return False
794
+
795
+ # --- State nodes ---
796
+ text_node: NodeImpl[str] = state(text, name="text")
797
+ font_node: NodeImpl[str] = state(font, name="font")
798
+ line_height_node: NodeImpl[float] = state(line_height, name="line-height")
799
+ max_width_node: NodeImpl[float] = state(max(0.0, max_width), name="max-width")
800
+
801
+ # --- Derived: segments (text + font → PreparedSegment[]) ---
802
+ def _compute_segments(deps: list[Any], _actions: Any) -> list[PreparedSegment]:
803
+ text_val: str = deps[0]
804
+ font_val: str = deps[1]
805
+
806
+ t0 = monotonic_ns()
807
+ measure_stats: dict[str, int] = {"hits": 0, "misses": 0}
808
+ result = analyze_and_measure(text_val, font_val, adapter, measure_cache, measure_stats)
809
+ elapsed = monotonic_ns() - t0
810
+
811
+ lookups = measure_stats["hits"] + measure_stats["misses"]
812
+ hit_rate = 1.0 if lookups == 0 else measure_stats["hits"] / lookups
813
+
814
+ # Phase-3 deferral: meta companion values arrive after parent's own
815
+ # DATA has propagated through phase-2 (parity with TS emitWithBatchPhase3).
816
+ meta = segments_node.meta
817
+ if meta:
818
+ cr = meta.get("cache-hit-rate")
819
+ if cr is not None:
820
+ emit_with_batch(cr.down, [(MessageType.DATA, hit_rate)], phase=3)
821
+ sc = meta.get("segment-count")
822
+ if sc is not None:
823
+ emit_with_batch(sc.down, [(MessageType.DATA, len(result))], phase=3)
824
+ lt = meta.get("layout-time-ns")
825
+ if lt is not None:
826
+ emit_with_batch(lt.down, [(MessageType.DATA, elapsed)], phase=3)
827
+
828
+ return result
829
+
830
+ def _segments_equals(a: list[PreparedSegment] | None, b: list[PreparedSegment] | None) -> bool:
831
+ if a is None or b is None:
832
+ return a is b
833
+ if len(a) != len(b):
834
+ return False
835
+ return all(
836
+ sa.text == sb.text and sa.width == sb.width and sa.grapheme_widths == sb.grapheme_widths
837
+ for sa, sb in zip(a, b, strict=True)
838
+ )
839
+
840
+ segments_node: NodeImpl[list[PreparedSegment]] = derived(
841
+ [text_node, font_node],
842
+ _compute_segments,
843
+ name="segments",
844
+ meta={"cache-hit-rate": 0, "segment-count": 0, "layout-time-ns": 0},
845
+ equals=_segments_equals,
846
+ on_message=lambda msg, _dep_index, _actions: _invalidate_measure_cache(
847
+ msg, measure_cache, adapter
848
+ ),
849
+ )
850
+
851
+ # --- Derived: line-breaks (segments + max-width + font → LineBreaksResult) ---
852
+ def _compute_line_breaks(deps: list[Any], _actions: Any) -> LineBreaksResult:
853
+ segs: list[PreparedSegment] = deps[0]
854
+ mw: float = deps[1]
855
+ f: str = deps[2]
856
+ return compute_line_breaks(segs, mw, adapter, f, measure_cache)
857
+
858
+ def _line_breaks_equals(a: LineBreaksResult | None, b: LineBreaksResult | None) -> bool:
859
+ if a is None or b is None:
860
+ return a is b
861
+ if a.line_count != b.line_count:
862
+ return False
863
+ return all(
864
+ la.text == lb.text
865
+ and la.width == lb.width
866
+ and la.start_segment == lb.start_segment
867
+ and la.start_grapheme == lb.start_grapheme
868
+ and la.end_segment == lb.end_segment
869
+ and la.end_grapheme == lb.end_grapheme
870
+ for la, lb in zip(a.lines, b.lines, strict=True)
871
+ )
872
+
873
+ line_breaks_node: NodeImpl[LineBreaksResult] = derived(
874
+ [segments_node, max_width_node, font_node],
875
+ _compute_line_breaks,
876
+ name="line-breaks",
877
+ equals=_line_breaks_equals,
878
+ )
879
+
880
+ # --- Derived: height ---
881
+ height_node: NodeImpl[float] = derived(
882
+ [line_breaks_node, line_height_node],
883
+ lambda deps, _a: deps[0].line_count * deps[1],
884
+ name="height",
885
+ )
886
+
887
+ # --- Derived: char-positions ---
888
+ def _compute_char_positions(deps: list[Any], _actions: Any) -> list[CharPosition]:
889
+ lb: LineBreaksResult = deps[0]
890
+ segs: list[PreparedSegment] = deps[1]
891
+ lh: float = deps[2]
892
+ return compute_char_positions(lb, segs, lh)
893
+
894
+ def _char_positions_equals(a: list[CharPosition] | None, b: list[CharPosition] | None) -> bool:
895
+ if a is None or b is None:
896
+ return a is b
897
+ if len(a) != len(b):
898
+ return False
899
+ return all(
900
+ ca.x == cb.x and ca.y == cb.y and ca.width == cb.width
901
+ for ca, cb in zip(a, b, strict=True)
902
+ )
903
+
904
+ char_positions_node: NodeImpl[list[CharPosition]] = derived(
905
+ [line_breaks_node, segments_node, line_height_node],
906
+ _compute_char_positions,
907
+ name="char-positions",
908
+ equals=_char_positions_equals,
909
+ )
910
+
911
+ # --- Register in graph ---
912
+ g.add("text", text_node)
913
+ g.add("font", font_node)
914
+ g.add("line-height", line_height_node)
915
+ g.add("max-width", max_width_node)
916
+ g.add("segments", segments_node)
917
+ g.add("line-breaks", line_breaks_node)
918
+ g.add("height", height_node)
919
+ g.add("char-positions", char_positions_node)
920
+
921
+ # --- Edges (for describe() visibility) ---
922
+ g.connect("text", "segments")
923
+ g.connect("font", "segments")
924
+ g.connect("segments", "line-breaks")
925
+ g.connect("max-width", "line-breaks")
926
+ g.connect("font", "line-breaks")
927
+ g.connect("line-breaks", "height")
928
+ g.connect("line-height", "height")
929
+ g.connect("line-breaks", "char-positions")
930
+ g.connect("segments", "char-positions")
931
+ g.connect("line-height", "char-positions")
932
+
933
+ return ReactiveLayoutBundle(
934
+ graph=g,
935
+ set_text=lambda t: g.set("text", t),
936
+ set_font=lambda f: g.set("font", f),
937
+ set_line_height=lambda lh: g.set("line-height", lh),
938
+ set_max_width=lambda mw: g.set("max-width", max(0.0, mw)),
939
+ segments=segments_node,
940
+ line_breaks=line_breaks_node,
941
+ height=height_node,
942
+ char_positions=char_positions_node,
943
+ )