flexily 0.5.2 → 0.6.0
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.
- package/dist/chunk-CBBoxR_p.mjs +26 -0
- package/dist/constants-BNURa6H7.mjs +65 -0
- package/dist/constants-BNURa6H7.mjs.map +1 -0
- package/dist/constants-D7ythAJC.d.mts +64 -0
- package/dist/constants-D7ythAJC.d.mts.map +1 -0
- package/{src/classic/node.ts → dist/index-classic.d.mts} +118 -619
- package/dist/index-classic.d.mts.map +1 -0
- package/dist/index-classic.mjs +1909 -0
- package/dist/index-classic.mjs.map +1 -0
- package/dist/index.d.mts +195 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3279 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-zero-75maLs2s.d.mts +762 -0
- package/dist/node-zero-75maLs2s.d.mts.map +1 -0
- package/dist/src-BWyhokNZ.mjs +692 -0
- package/dist/src-BWyhokNZ.mjs.map +1 -0
- package/dist/src-DdSLylRA.mjs +816 -0
- package/dist/src-DdSLylRA.mjs.map +1 -0
- package/dist/testing.d.mts +55 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +154 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types--IozHd4V.mjs +283 -0
- package/dist/types--IozHd4V.mjs.map +1 -0
- package/dist/types-DG1H4DVR.d.mts +157 -0
- package/dist/types-DG1H4DVR.d.mts.map +1 -0
- package/package.json +33 -24
- package/src/CLAUDE.md +0 -527
- package/src/classic/layout.ts +0 -1843
- package/src/constants.ts +0 -82
- package/src/create-flexily.ts +0 -153
- package/src/index-classic.ts +0 -110
- package/src/index.ts +0 -133
- package/src/layout-flex-lines.ts +0 -413
- package/src/layout-helpers.ts +0 -160
- package/src/layout-measure.ts +0 -259
- package/src/layout-stats.ts +0 -41
- package/src/layout-traversal.ts +0 -70
- package/src/layout-zero.ts +0 -2219
- package/src/logger.ts +0 -68
- package/src/monospace-measurer.ts +0 -68
- package/src/node-zero.ts +0 -1508
- package/src/pretext-measurer.ts +0 -86
- package/src/test-measurer.ts +0 -219
- package/src/testing.ts +0 -215
- package/src/text-layout.ts +0 -75
- package/src/trace.ts +0 -252
- package/src/types.ts +0 -236
- package/src/utils.ts +0 -243
package/src/pretext-measurer.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pretext text measurement plugin.
|
|
3
|
-
*
|
|
4
|
-
* Integrates with @chenglou/pretext for proportional font measurement.
|
|
5
|
-
* Pretext is a peer dependency — users must install it separately.
|
|
6
|
-
*
|
|
7
|
-
* See silvery-internal/design/v05-layout/pretext-integration.md
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { FlexilyPlugin } from "./create-flexily.js"
|
|
11
|
-
import type { TextLayoutService, PreparedText, TextLayout, IntrinsicSizes } from "./text-layout.js"
|
|
12
|
-
|
|
13
|
-
/** Pretext API shape (from @chenglou/pretext). Defined here to avoid a hard dependency. */
|
|
14
|
-
export interface PretextAPI {
|
|
15
|
-
prepare(text: string, font: string): PretextPrepared
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface PretextPrepared {
|
|
19
|
-
layout(maxWidth: number, lineHeight?: number): PretextLayout
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface PretextLayout {
|
|
23
|
-
width: number
|
|
24
|
-
height: number
|
|
25
|
-
lines?: Array<{ text: string; width: number }>
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Create a Pretext-based text measurement service.
|
|
30
|
-
*
|
|
31
|
-
* @param pretext - The pretext module (import from "@chenglou/pretext")
|
|
32
|
-
*/
|
|
33
|
-
export function createPretextMeasurer(pretext: PretextAPI): TextLayoutService {
|
|
34
|
-
return {
|
|
35
|
-
prepare(input) {
|
|
36
|
-
const { text, style } = input
|
|
37
|
-
const pretextPrepared = pretext.prepare(text, style.fontShorthand)
|
|
38
|
-
const lineHeight = style.lineHeight || style.fontSize
|
|
39
|
-
|
|
40
|
-
let cachedIntrinsic: IntrinsicSizes | null = null
|
|
41
|
-
|
|
42
|
-
const prepared: PreparedText = {
|
|
43
|
-
intrinsicSizes(): IntrinsicSizes {
|
|
44
|
-
if (cachedIntrinsic) return cachedIntrinsic
|
|
45
|
-
const unconstrained = pretextPrepared.layout(Infinity, lineHeight)
|
|
46
|
-
const minLayout = pretextPrepared.layout(0, lineHeight)
|
|
47
|
-
cachedIntrinsic = {
|
|
48
|
-
minContentWidth: minLayout.width,
|
|
49
|
-
maxContentWidth: unconstrained.width,
|
|
50
|
-
}
|
|
51
|
-
return cachedIntrinsic
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
layout(constraints): TextLayout {
|
|
55
|
-
const maxWidth = constraints.maxWidth ?? Infinity
|
|
56
|
-
const result = pretextPrepared.layout(maxWidth, lineHeight)
|
|
57
|
-
const lineCount = result.lines?.length ?? 1
|
|
58
|
-
const width = constraints.shrinkWrap ? result.width : Math.min(maxWidth, result.width)
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
width,
|
|
62
|
-
height: result.height,
|
|
63
|
-
lineCount,
|
|
64
|
-
firstBaseline: lineHeight,
|
|
65
|
-
lastBaseline: lineCount > 0 ? (lineCount - 1) * lineHeight + lineHeight : lineHeight,
|
|
66
|
-
truncated: false,
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return prepared
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Plugin: add Pretext-based proportional text measurement.
|
|
78
|
-
*
|
|
79
|
-
* @param pretext - The pretext module (import from "@chenglou/pretext")
|
|
80
|
-
*/
|
|
81
|
-
export function withPretext(pretext: PretextAPI): FlexilyPlugin {
|
|
82
|
-
return (engine) => {
|
|
83
|
-
engine.textLayout = createPretextMeasurer(pretext)
|
|
84
|
-
return engine
|
|
85
|
-
}
|
|
86
|
-
}
|
package/src/test-measurer.ts
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic test text measurer.
|
|
3
|
-
*
|
|
4
|
-
* Fixed grapheme width table: Latin 0.8, CJK 1.0, emoji 1.8 (relative to fontSize).
|
|
5
|
-
* Deterministic across platforms — use in tests and CI.
|
|
6
|
-
* Supports word wrapping for realistic text layout testing.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { FlexilyPlugin } from "./create-flexily.js"
|
|
10
|
-
import type { TextLayoutService, PreparedText, TextLayout, TextLine, IntrinsicSizes } from "./text-layout.js"
|
|
11
|
-
|
|
12
|
-
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
13
|
-
|
|
14
|
-
/** Measure a single grapheme cluster with the deterministic width table. */
|
|
15
|
-
function graphemeWidth(grapheme: string, fontSize: number): number {
|
|
16
|
-
const cp = grapheme.codePointAt(0) ?? 0
|
|
17
|
-
|
|
18
|
-
// Emoji: ZWJ sequences, variation selectors, regional indicators
|
|
19
|
-
if (
|
|
20
|
-
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
21
|
-
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
22
|
-
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
23
|
-
cp === 0x200d ||
|
|
24
|
-
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
25
|
-
grapheme.length > 2
|
|
26
|
-
) {
|
|
27
|
-
return fontSize * 1.8
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// CJK: Chinese, Japanese, Korean ideographs + fullwidth
|
|
31
|
-
if (
|
|
32
|
-
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
33
|
-
(cp >= 0x3000 && cp <= 0x303f) ||
|
|
34
|
-
(cp >= 0x3040 && cp <= 0x309f) ||
|
|
35
|
-
(cp >= 0x30a0 && cp <= 0x30ff) ||
|
|
36
|
-
(cp >= 0xac00 && cp <= 0xd7af) ||
|
|
37
|
-
(cp >= 0xff00 && cp <= 0xffef)
|
|
38
|
-
) {
|
|
39
|
-
return fontSize * 1.0
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Latin and everything else
|
|
43
|
-
return fontSize * 0.8
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** A word or whitespace segment for line breaking. */
|
|
47
|
-
interface TextSegment {
|
|
48
|
-
text: string
|
|
49
|
-
width: number
|
|
50
|
-
isWhitespace: boolean
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function segmentText(text: string, fontSize: number): TextSegment[] {
|
|
54
|
-
const segments: TextSegment[] = []
|
|
55
|
-
let current = ""
|
|
56
|
-
let currentWidth = 0
|
|
57
|
-
let isWhitespace = false
|
|
58
|
-
|
|
59
|
-
for (const { segment } of segmenter.segment(text)) {
|
|
60
|
-
const isSpace = segment === " " || segment === "\t"
|
|
61
|
-
|
|
62
|
-
if (segments.length === 0 && current === "") {
|
|
63
|
-
isWhitespace = isSpace
|
|
64
|
-
current = segment
|
|
65
|
-
currentWidth = isSpace ? fontSize * 0.8 : graphemeWidth(segment, fontSize)
|
|
66
|
-
continue
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (isSpace !== isWhitespace) {
|
|
70
|
-
if (current) segments.push({ text: current, width: currentWidth, isWhitespace })
|
|
71
|
-
current = segment
|
|
72
|
-
currentWidth = isSpace ? fontSize * 0.8 : graphemeWidth(segment, fontSize)
|
|
73
|
-
isWhitespace = isSpace
|
|
74
|
-
} else {
|
|
75
|
-
current += segment
|
|
76
|
-
currentWidth += isSpace ? fontSize * 0.8 : graphemeWidth(segment, fontSize)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (current) segments.push({ text: current, width: currentWidth, isWhitespace })
|
|
81
|
-
return segments
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function measureString(text: string, fontSize: number): number {
|
|
85
|
-
let width = 0
|
|
86
|
-
for (const { segment } of segmenter.segment(text)) {
|
|
87
|
-
width += graphemeWidth(segment, fontSize)
|
|
88
|
-
}
|
|
89
|
-
return width
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Create a deterministic text measurement service for testing.
|
|
94
|
-
*
|
|
95
|
-
* Uses fixed grapheme widths: Latin 0.8, CJK 1.0, emoji 1.8 (relative to fontSize).
|
|
96
|
-
*/
|
|
97
|
-
export function createTestMeasurer(): TextLayoutService {
|
|
98
|
-
return {
|
|
99
|
-
prepare(input) {
|
|
100
|
-
const { text, style } = input
|
|
101
|
-
const fontSize = style.fontSize
|
|
102
|
-
const lineHeight = style.lineHeight || fontSize
|
|
103
|
-
|
|
104
|
-
const segments = segmentText(text, fontSize)
|
|
105
|
-
|
|
106
|
-
let maxContentWidth = 0
|
|
107
|
-
let minContentWidth = 0
|
|
108
|
-
for (const seg of segments) {
|
|
109
|
-
maxContentWidth += seg.width
|
|
110
|
-
if (!seg.isWhitespace && seg.width > minContentWidth) {
|
|
111
|
-
minContentWidth = seg.width
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const prepared: PreparedText = {
|
|
116
|
-
intrinsicSizes(): IntrinsicSizes {
|
|
117
|
-
return { minContentWidth, maxContentWidth }
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
layout(constraints, options?): TextLayout {
|
|
121
|
-
const maxWidth = constraints.maxWidth ?? Infinity
|
|
122
|
-
const maxLines = constraints.maxLines ?? Infinity
|
|
123
|
-
const wrap = constraints.wrap ?? "normal"
|
|
124
|
-
const includeLines = options?.includeLines ?? false
|
|
125
|
-
|
|
126
|
-
if (wrap === "none" || maxWidth >= maxContentWidth) {
|
|
127
|
-
const truncated = maxLines < 1
|
|
128
|
-
const width = constraints.shrinkWrap ? maxContentWidth : Math.min(maxWidth, maxContentWidth)
|
|
129
|
-
return {
|
|
130
|
-
width,
|
|
131
|
-
height: truncated ? 0 : lineHeight,
|
|
132
|
-
lineCount: truncated ? 0 : 1,
|
|
133
|
-
firstBaseline: lineHeight,
|
|
134
|
-
lastBaseline: lineHeight,
|
|
135
|
-
truncated: maxWidth < maxContentWidth,
|
|
136
|
-
lines: includeLines
|
|
137
|
-
? [{ text, width: maxContentWidth, startIndex: 0, endIndex: text.length }]
|
|
138
|
-
: undefined,
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Word wrapping
|
|
143
|
-
const lines: TextLine[] = []
|
|
144
|
-
let lineWidth = 0
|
|
145
|
-
let lineText = ""
|
|
146
|
-
let lineStart = 0
|
|
147
|
-
let charIndex = 0
|
|
148
|
-
|
|
149
|
-
for (const seg of segments) {
|
|
150
|
-
if (seg.isWhitespace) {
|
|
151
|
-
if (lineText) {
|
|
152
|
-
lineWidth += seg.width
|
|
153
|
-
lineText += seg.text
|
|
154
|
-
}
|
|
155
|
-
charIndex += seg.text.length
|
|
156
|
-
continue
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (lineWidth + seg.width > maxWidth && lineText) {
|
|
160
|
-
const trimmed = lineText.trimEnd()
|
|
161
|
-
lines.push({
|
|
162
|
-
text: trimmed,
|
|
163
|
-
width: measureString(trimmed, fontSize),
|
|
164
|
-
startIndex: lineStart,
|
|
165
|
-
endIndex: charIndex,
|
|
166
|
-
})
|
|
167
|
-
if (lines.length >= maxLines) break
|
|
168
|
-
|
|
169
|
-
lineText = seg.text
|
|
170
|
-
lineWidth = seg.width
|
|
171
|
-
lineStart = charIndex
|
|
172
|
-
} else {
|
|
173
|
-
lineText += seg.text
|
|
174
|
-
lineWidth += seg.width
|
|
175
|
-
}
|
|
176
|
-
charIndex += seg.text.length
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (lineText && lines.length < maxLines) {
|
|
180
|
-
const trimmed = lineText.trimEnd()
|
|
181
|
-
lines.push({
|
|
182
|
-
text: trimmed,
|
|
183
|
-
width: measureString(trimmed, fontSize),
|
|
184
|
-
startIndex: lineStart,
|
|
185
|
-
endIndex: charIndex,
|
|
186
|
-
})
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const lineCount = lines.length
|
|
190
|
-
const height = lineCount * lineHeight
|
|
191
|
-
const widestLine = lines.reduce((max, l) => Math.max(max, l.width), 0)
|
|
192
|
-
const width = constraints.shrinkWrap ? widestLine : Math.min(maxWidth, widestLine)
|
|
193
|
-
|
|
194
|
-
return {
|
|
195
|
-
width,
|
|
196
|
-
height,
|
|
197
|
-
lineCount,
|
|
198
|
-
firstBaseline: lineHeight,
|
|
199
|
-
lastBaseline: lineCount > 0 ? (lineCount - 1) * lineHeight + lineHeight : lineHeight,
|
|
200
|
-
truncated: lines.length >= maxLines && charIndex < text.length,
|
|
201
|
-
lines: includeLines ? lines : undefined,
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return prepared
|
|
207
|
-
},
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Plugin: add deterministic test text measurement to the engine.
|
|
213
|
-
*/
|
|
214
|
-
export function withTestMeasurer(): FlexilyPlugin {
|
|
215
|
-
return (engine) => {
|
|
216
|
-
engine.textLayout = createTestMeasurer()
|
|
217
|
-
return engine
|
|
218
|
-
}
|
|
219
|
-
}
|
package/src/testing.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Flexily Testing Utilities
|
|
3
|
-
*
|
|
4
|
-
* Diagnostic helpers for verifying layout correctness, especially
|
|
5
|
-
* incremental re-layout consistency. Used by downstream consumers
|
|
6
|
-
* (silvery, km-tui) and flexily's own test suite.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```typescript
|
|
10
|
-
* import { Node, DIRECTION_LTR } from 'flexily';
|
|
11
|
-
* import { getLayout, diffLayouts, assertLayoutSanity } from 'flexily/testing';
|
|
12
|
-
*
|
|
13
|
-
* const root = Node.create();
|
|
14
|
-
* root.setWidth(80);
|
|
15
|
-
* root.calculateLayout(80, 24, DIRECTION_LTR);
|
|
16
|
-
* assertLayoutSanity(root);
|
|
17
|
-
* const layout = getLayout(root);
|
|
18
|
-
* ```
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { DIRECTION_LTR, MEASURE_MODE_AT_MOST, MEASURE_MODE_EXACTLY } from "./constants.js"
|
|
22
|
-
import type { MeasureFunc } from "./types.js"
|
|
23
|
-
import { Node } from "./node-zero.js"
|
|
24
|
-
|
|
25
|
-
// ============================================================================
|
|
26
|
-
// Types
|
|
27
|
-
// ============================================================================
|
|
28
|
-
|
|
29
|
-
export interface LayoutResult {
|
|
30
|
-
left: number
|
|
31
|
-
top: number
|
|
32
|
-
width: number
|
|
33
|
-
height: number
|
|
34
|
-
children: LayoutResult[]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface BuildTreeResult {
|
|
38
|
-
root: Node
|
|
39
|
-
dirtyTargets: Node[]
|
|
40
|
-
direction?: number // DIRECTION_LTR or DIRECTION_RTL, defaults to LTR
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ============================================================================
|
|
44
|
-
// Layout Inspection
|
|
45
|
-
// ============================================================================
|
|
46
|
-
|
|
47
|
-
/** Recursively extract computed layout from a node tree. */
|
|
48
|
-
export function getLayout(node: Node): LayoutResult {
|
|
49
|
-
return {
|
|
50
|
-
left: node.getComputedLeft(),
|
|
51
|
-
top: node.getComputedTop(),
|
|
52
|
-
width: node.getComputedWidth(),
|
|
53
|
-
height: node.getComputedHeight(),
|
|
54
|
-
children: Array.from({ length: node.getChildCount() }, (_, i) => getLayout(node.getChild(i)!)),
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Format a layout tree as an indented string for debugging. */
|
|
59
|
-
export function formatLayout(layout: LayoutResult, indent = 0): string {
|
|
60
|
-
const pad = " ".repeat(indent)
|
|
61
|
-
let result = `${pad}{ left: ${layout.left}, top: ${layout.top}, width: ${layout.width}, height: ${layout.height} }`
|
|
62
|
-
if (layout.children.length > 0) {
|
|
63
|
-
result += ` [\n${layout.children.map((c) => formatLayout(c, indent + 1)).join(",\n")}\n${pad}]`
|
|
64
|
-
}
|
|
65
|
-
return result
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Collect node-by-node diffs between two layout trees.
|
|
70
|
-
* Returns empty array if layouts match.
|
|
71
|
-
*/
|
|
72
|
-
export function diffLayouts(a: LayoutResult, b: LayoutResult, path = "root"): string[] {
|
|
73
|
-
const diffs: string[] = []
|
|
74
|
-
// Use Object.is for NaN-safe comparison (NaN === NaN is false, Object.is(NaN, NaN) is true)
|
|
75
|
-
if (!Object.is(a.left, b.left)) diffs.push(`${path}: left ${a.left} vs ${b.left}`)
|
|
76
|
-
if (!Object.is(a.top, b.top)) diffs.push(`${path}: top ${a.top} vs ${b.top}`)
|
|
77
|
-
if (!Object.is(a.width, b.width)) diffs.push(`${path}: width ${a.width} vs ${b.width}`)
|
|
78
|
-
if (!Object.is(a.height, b.height)) diffs.push(`${path}: height ${a.height} vs ${b.height}`)
|
|
79
|
-
for (let i = 0; i < Math.max(a.children.length, b.children.length); i++) {
|
|
80
|
-
if (a.children[i] && b.children[i]) {
|
|
81
|
-
diffs.push(...diffLayouts(a.children[i]!, b.children[i]!, `${path}[${i}]`))
|
|
82
|
-
} else if (a.children[i]) {
|
|
83
|
-
diffs.push(`${path}[${i}]: missing in incremental`)
|
|
84
|
-
} else {
|
|
85
|
-
diffs.push(`${path}[${i}]: missing in reference`)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return diffs
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ============================================================================
|
|
92
|
-
// Measure Functions
|
|
93
|
-
// ============================================================================
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Wrapping text measure function factory.
|
|
97
|
-
* Simulates text of given width that wraps to multiple lines when constrained.
|
|
98
|
-
*/
|
|
99
|
-
export function textMeasure(textWidth: number): MeasureFunc {
|
|
100
|
-
return (width: number, widthMode: number, _height: number, _heightMode: number) => {
|
|
101
|
-
if (widthMode === MEASURE_MODE_EXACTLY || widthMode === MEASURE_MODE_AT_MOST) {
|
|
102
|
-
if (width >= textWidth) return { width: textWidth, height: 1 }
|
|
103
|
-
const lines = Math.ceil(textWidth / width)
|
|
104
|
-
return { width: Math.min(textWidth, width), height: lines }
|
|
105
|
-
}
|
|
106
|
-
return { width: textWidth, height: 1 }
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ============================================================================
|
|
111
|
-
// Assertions (throw on failure, no vitest dependency)
|
|
112
|
-
// ============================================================================
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Assert that all layout values are non-negative and width is finite.
|
|
116
|
-
* Height may be NaN for auto-height trees with unconstrained height.
|
|
117
|
-
* Throws a descriptive error on failure.
|
|
118
|
-
*/
|
|
119
|
-
export function assertLayoutSanity(node: Node, path = "root"): void {
|
|
120
|
-
const w = node.getComputedWidth()
|
|
121
|
-
const h = node.getComputedHeight()
|
|
122
|
-
const l = node.getComputedLeft()
|
|
123
|
-
const t = node.getComputedTop()
|
|
124
|
-
|
|
125
|
-
// Width and height should be non-negative when finite.
|
|
126
|
-
// They can be NaN for auto-sized nodes in unconstrained containers (e.g., absolute children).
|
|
127
|
-
if (Number.isFinite(w) && w < 0) throw new Error(`${path}: width is negative (${w})`)
|
|
128
|
-
if (Number.isFinite(h) && h < 0) throw new Error(`${path}: height is negative (${h})`)
|
|
129
|
-
// Position values can be negative (absolute positioning) or non-finite (unconstrained containers).
|
|
130
|
-
// Only check that width is not Infinity (NaN is ok for auto-sized).
|
|
131
|
-
if (w === Infinity || w === -Infinity) throw new Error(`${path}: width is Infinity`)
|
|
132
|
-
|
|
133
|
-
for (let i = 0; i < node.getChildCount(); i++) {
|
|
134
|
-
assertLayoutSanity(node.getChild(i)!, `${path}[${i}]`)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Differential oracle: re-layout of partially-dirty tree must match fresh layout.
|
|
140
|
-
* Throws a descriptive error with node-by-node diff on failure.
|
|
141
|
-
*/
|
|
142
|
-
export function expectRelayoutMatchesFresh(
|
|
143
|
-
buildTree: () => BuildTreeResult,
|
|
144
|
-
layoutWidth: number,
|
|
145
|
-
layoutHeight: number,
|
|
146
|
-
): void {
|
|
147
|
-
// 1. Build, layout, mark dirty, re-layout
|
|
148
|
-
const { root, dirtyTargets, direction: dir } = buildTree()
|
|
149
|
-
const layoutDir = dir ?? DIRECTION_LTR
|
|
150
|
-
root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
|
|
151
|
-
for (const t of dirtyTargets) t.markDirty()
|
|
152
|
-
root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
|
|
153
|
-
const incremental = getLayout(root)
|
|
154
|
-
|
|
155
|
-
// 2. Build identical fresh tree, layout once
|
|
156
|
-
const fresh = buildTree()
|
|
157
|
-
fresh.root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
|
|
158
|
-
const reference = getLayout(fresh.root)
|
|
159
|
-
|
|
160
|
-
// 3. Must be identical — show detailed diff on failure
|
|
161
|
-
const diffs = diffLayouts(reference, incremental)
|
|
162
|
-
if (diffs.length > 0) {
|
|
163
|
-
const detail = diffs.map((d) => ` ${d}`).join("\n")
|
|
164
|
-
throw new Error(
|
|
165
|
-
`Incremental layout differs from fresh (${diffs.length} diffs):\n${detail}\n\nFresh:\n${formatLayout(reference)}\n\nIncremental:\n${formatLayout(incremental)}`,
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Assert that laying out twice with identical constraints produces identical results.
|
|
172
|
-
* Catches non-determinism or state corruption from a single layout pass.
|
|
173
|
-
*/
|
|
174
|
-
export function expectIdempotent(buildTree: () => BuildTreeResult, layoutWidth: number, layoutHeight: number): void {
|
|
175
|
-
const { root, direction: dir } = buildTree()
|
|
176
|
-
const layoutDir = dir ?? DIRECTION_LTR
|
|
177
|
-
root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
|
|
178
|
-
const first = getLayout(root)
|
|
179
|
-
root.calculateLayout(layoutWidth, layoutHeight, layoutDir)
|
|
180
|
-
const second = getLayout(root)
|
|
181
|
-
|
|
182
|
-
const diffs = diffLayouts(first, second)
|
|
183
|
-
if (diffs.length > 0) {
|
|
184
|
-
const detail = diffs.map((d) => ` ${d}`).join("\n")
|
|
185
|
-
throw new Error(`Layout not idempotent (${diffs.length} diffs after 2nd pass):\n${detail}`)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Assert that layout at width W, then different width W', then back to W
|
|
191
|
-
* produces the same result as fresh layout at W.
|
|
192
|
-
* Catches stale cache entries that don't update on constraint change.
|
|
193
|
-
*/
|
|
194
|
-
export function expectResizeRoundTrip(buildTree: () => BuildTreeResult, widths: number[]): void {
|
|
195
|
-
const { root, direction: dir } = buildTree()
|
|
196
|
-
const layoutDir = dir ?? DIRECTION_LTR
|
|
197
|
-
for (const w of widths) {
|
|
198
|
-
root.setWidth(w)
|
|
199
|
-
root.calculateLayout(w, NaN, layoutDir)
|
|
200
|
-
}
|
|
201
|
-
const incremental = getLayout(root)
|
|
202
|
-
|
|
203
|
-
// Fresh reference at final width
|
|
204
|
-
const finalWidth = widths[widths.length - 1]!
|
|
205
|
-
const fresh = buildTree()
|
|
206
|
-
fresh.root.setWidth(finalWidth)
|
|
207
|
-
fresh.root.calculateLayout(finalWidth, NaN, layoutDir)
|
|
208
|
-
const reference = getLayout(fresh.root)
|
|
209
|
-
|
|
210
|
-
const diffs = diffLayouts(reference, incremental)
|
|
211
|
-
if (diffs.length > 0) {
|
|
212
|
-
const detail = diffs.map((d) => ` ${d}`).join("\n")
|
|
213
|
-
throw new Error(`Resize round-trip differs (widths: ${widths.join("→")}, ${diffs.length} diffs):\n${detail}`)
|
|
214
|
-
}
|
|
215
|
-
}
|
package/src/text-layout.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Text Layout Service — pluggable text measurement for Flexily.
|
|
3
|
-
*
|
|
4
|
-
* The TextLayoutService interface decouples text measurement from the layout engine.
|
|
5
|
-
* Different backends handle different environments:
|
|
6
|
-
* - MonospaceMeasurer: terminal (1 char = 1 cell)
|
|
7
|
-
* - DeterministicTestMeasurer: tests/CI (fixed grapheme widths)
|
|
8
|
-
* - PretextMeasurer: proportional fonts (Canvas measureText)
|
|
9
|
-
*
|
|
10
|
-
* See silvery-internal/design/v05-layout/pretext-integration.md for design rationale.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Resolved text style — consumed by both measurement and painting. */
|
|
14
|
-
export interface ResolvedTextStyle {
|
|
15
|
-
fontShorthand: string // e.g. "14px 'Inter', sans-serif"
|
|
16
|
-
fontFamily: string
|
|
17
|
-
fontSize: number
|
|
18
|
-
fontWeight: number
|
|
19
|
-
fontStyle: string
|
|
20
|
-
lineHeight: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Input for text preparation. */
|
|
24
|
-
export interface TextPrepareInput {
|
|
25
|
-
text: string
|
|
26
|
-
style: ResolvedTextStyle
|
|
27
|
-
direction?: "auto" | "ltr" | "rtl"
|
|
28
|
-
locale?: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Intrinsic text sizes for flexbox min/max-content. */
|
|
32
|
-
export interface IntrinsicSizes {
|
|
33
|
-
minContentWidth: number // longest unbreakable segment
|
|
34
|
-
maxContentWidth: number // unwrapped total width
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Constraints for text layout. */
|
|
38
|
-
export interface TextConstraints {
|
|
39
|
-
maxWidth?: number
|
|
40
|
-
maxHeight?: number
|
|
41
|
-
maxLines?: number
|
|
42
|
-
wrap?: "normal" | "anywhere" | "none"
|
|
43
|
-
overflow?: "clip" | "ellipsis"
|
|
44
|
-
shrinkWrap?: boolean
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** A single laid-out line of text. */
|
|
48
|
-
export interface TextLine {
|
|
49
|
-
text: string
|
|
50
|
-
width: number
|
|
51
|
-
startIndex: number
|
|
52
|
-
endIndex: number
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Result of laying out prepared text at a specific width. */
|
|
56
|
-
export interface TextLayout {
|
|
57
|
-
width: number
|
|
58
|
-
height: number
|
|
59
|
-
lineCount: number
|
|
60
|
-
firstBaseline: number
|
|
61
|
-
lastBaseline: number
|
|
62
|
-
truncated: boolean
|
|
63
|
-
lines?: readonly TextLine[]
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Prepared text — measured and segmented, ready for layout at any width. */
|
|
67
|
-
export interface PreparedText {
|
|
68
|
-
intrinsicSizes(): IntrinsicSizes
|
|
69
|
-
layout(constraints: TextConstraints, options?: { includeLines?: boolean }): TextLayout
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Pluggable text measurement backend. */
|
|
73
|
-
export interface TextLayoutService {
|
|
74
|
-
prepare(input: TextPrepareInput): PreparedText
|
|
75
|
-
}
|