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,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
|
+
)
|