flexily 0.3.3 → 0.5.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/README.md +78 -10
- package/package.json +2 -2
- package/src/create-flexily.ts +153 -0
- package/src/index.ts +38 -15
- package/src/monospace-measurer.ts +68 -0
- package/src/pretext-measurer.ts +86 -0
- package/src/test-measurer.ts +219 -0
- package/src/text-layout.ts +75 -0
package/README.md
CHANGED
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/flexily)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
+
**Composable API** (recommended):
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
import { createFlexily, FLEX_DIRECTION_ROW } from "flexily"
|
|
12
|
+
|
|
13
|
+
const flex = createFlexily()
|
|
14
|
+
const root = flex.createNode()
|
|
15
|
+
root.setWidth(80)
|
|
16
|
+
root.setFlexDirection(FLEX_DIRECTION_ROW)
|
|
17
|
+
|
|
18
|
+
const label = flex.createNode()
|
|
19
|
+
label.setTextContent("Hello") // auto-measured: 5 wide
|
|
20
|
+
|
|
21
|
+
const content = flex.createNode()
|
|
22
|
+
content.setFlexGrow(1)
|
|
23
|
+
content.setTextContent("World")
|
|
24
|
+
|
|
25
|
+
root.insertChild(label, 0)
|
|
26
|
+
root.insertChild(content, 1)
|
|
27
|
+
flex.calculateLayout(root, 80, 1)
|
|
28
|
+
// label: 5 wide, content: 75 wide
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Low-level Yoga-compatible API:**
|
|
32
|
+
|
|
8
33
|
```typescript
|
|
9
34
|
import { Node, FLEX_DIRECTION_ROW, DIRECTION_LTR } from "flexily"
|
|
10
35
|
|
|
@@ -47,9 +72,47 @@ Most developers should use a framework built on Flexily, not Flexily directly. F
|
|
|
47
72
|
|
|
48
73
|
> **Building a terminal UI?** Use [silvery](https://silvery.dev), which uses Flexily by default. You get React components, hooks, and layout feedback without touching the low-level API.
|
|
49
74
|
|
|
75
|
+
## Composable API
|
|
76
|
+
|
|
77
|
+
Flexily v0.5+ includes a composable engine with built-in text measurement:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { createFlexily } from "flexily"
|
|
81
|
+
|
|
82
|
+
const flex = createFlexily()
|
|
83
|
+
const root = flex.createNode()
|
|
84
|
+
root.setWidth(80)
|
|
85
|
+
root.setFlexDirection(FLEX_DIRECTION_ROW)
|
|
86
|
+
|
|
87
|
+
const label = flex.createNode()
|
|
88
|
+
label.setTextContent("Hello")
|
|
89
|
+
|
|
90
|
+
const content = flex.createNode()
|
|
91
|
+
content.setFlexGrow(1)
|
|
92
|
+
content.setTextContent("World")
|
|
93
|
+
|
|
94
|
+
root.insertChild(label, 0)
|
|
95
|
+
root.insertChild(content, 1)
|
|
96
|
+
flex.calculateLayout(root, 80, 1)
|
|
97
|
+
// label: 5 wide, content: 75 wide
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
For custom text measurement, compose plugins with `pipe()`:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { createBareFlexily, pipe, withTestMeasurer } from "flexily"
|
|
104
|
+
|
|
105
|
+
const flex = pipe(createBareFlexily(), withTestMeasurer())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Text measurement backends:
|
|
109
|
+
- **`withMonospace()`** — terminal grids (1 char = 1 cell), default
|
|
110
|
+
- **`withTestMeasurer()`** — deterministic widths for CI (Latin 0.8, CJK 1.0, emoji 1.8)
|
|
111
|
+
- **`withPretext(pretext)`** — proportional fonts via [Pretext](https://github.com/chenglou/pretext)
|
|
112
|
+
|
|
50
113
|
## Status
|
|
51
114
|
|
|
52
|
-
|
|
115
|
+
1561 tests, including 44 Yoga compatibility tests and 1200+ incremental re-layout fuzz tests. Used by [silvery](https://silvery.dev) as its default layout engine.
|
|
53
116
|
|
|
54
117
|
| Feature | Status |
|
|
55
118
|
| --------------------------------------------- | -------- |
|
|
@@ -64,6 +127,8 @@ Most developers should use a framework built on Flexily, not Flexily directly. F
|
|
|
64
127
|
| Logical edges (EDGE_START/END) | Complete |
|
|
65
128
|
| RTL support | Complete |
|
|
66
129
|
| Baseline alignment | Complete |
|
|
130
|
+
| Composable engine (createFlexily, pipe) | Complete |
|
|
131
|
+
| Text measurement (monospace, proportional) | Complete |
|
|
67
132
|
|
|
68
133
|
## Installation
|
|
69
134
|
|
|
@@ -184,15 +249,18 @@ Same constants, same method names, same behavior.
|
|
|
184
249
|
|
|
185
250
|
```
|
|
186
251
|
src/
|
|
187
|
-
├── index.ts
|
|
188
|
-
├──
|
|
189
|
-
├── layout
|
|
190
|
-
├──
|
|
191
|
-
├──
|
|
192
|
-
├──
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
252
|
+
├── index.ts # Main export (everything)
|
|
253
|
+
├── create-flexily.ts # createFlexily, createBareFlexily, pipe, FlexilyNode
|
|
254
|
+
├── text-layout.ts # TextLayoutService, PreparedText interfaces
|
|
255
|
+
├── monospace-measurer.ts # Terminal text measurement (1 char = 1 cell)
|
|
256
|
+
├── test-measurer.ts # Deterministic test measurer
|
|
257
|
+
├── pretext-measurer.ts # Proportional font measurement (peer dep)
|
|
258
|
+
├── node-zero.ts # Node class with FlexInfo
|
|
259
|
+
├── layout-zero.ts # Layout algorithm (~2000 lines)
|
|
260
|
+
├── constants.ts # Flexbox constants (Yoga-compatible)
|
|
261
|
+
├── types.ts # TypeScript interfaces
|
|
262
|
+
├── utils.ts # Shared utilities
|
|
263
|
+
└── classic/ # Allocating algorithm (for debugging)
|
|
196
264
|
```
|
|
197
265
|
|
|
198
266
|
## License
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexily",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pure JavaScript flexbox layout engine —
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Pure JavaScript flexbox layout engine — composable plugins, text measurement, Yoga-compatible API, no WASM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"canvas-ui",
|
|
7
7
|
"css",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable Flexily engine — createFlexily, createBareFlexily, pipe.
|
|
3
|
+
*
|
|
4
|
+
* FlexilyNode = Node + { setTextContent, getTextContent }.
|
|
5
|
+
* No wrapper — text methods are mixed directly onto the Node instance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Node } from "./node-zero.js"
|
|
9
|
+
import { DIRECTION_LTR, MEASURE_MODE_UNDEFINED, MEASURE_MODE_AT_MOST } from "./constants.js"
|
|
10
|
+
import type { TextLayoutService, ResolvedTextStyle, PreparedText } from "./text-layout.js"
|
|
11
|
+
import { createMonospaceMeasurer } from "./monospace-measurer.js"
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// FlexilyNode — Node + text content mixin
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** A Node with text content methods. */
|
|
18
|
+
export interface FlexilyNode extends Node {
|
|
19
|
+
setTextContent(text: string, style?: Partial<ResolvedTextStyle>): void
|
|
20
|
+
getTextContent(): string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TEXT_STYLE: ResolvedTextStyle = {
|
|
24
|
+
fontShorthand: "1ch monospace",
|
|
25
|
+
fontFamily: "monospace",
|
|
26
|
+
fontSize: 1,
|
|
27
|
+
fontWeight: 400,
|
|
28
|
+
fontStyle: "normal",
|
|
29
|
+
lineHeight: 1,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mix text content methods onto a Node. Returns the same node typed as FlexilyNode. */
|
|
33
|
+
function mixTextContent(node: Node, engine: FlexilyEngine): FlexilyNode {
|
|
34
|
+
let textContent: string | null = null
|
|
35
|
+
let prepared: PreparedText | null = null
|
|
36
|
+
|
|
37
|
+
// Save originals before overriding — setTextContent needs to call the
|
|
38
|
+
// original setMeasureFunc without triggering text state clearing.
|
|
39
|
+
const origSetMeasure = node.setMeasureFunc.bind(node)
|
|
40
|
+
const origUnsetMeasure = node.unsetMeasureFunc.bind(node)
|
|
41
|
+
|
|
42
|
+
const flexNode = node as FlexilyNode
|
|
43
|
+
|
|
44
|
+
flexNode.setTextContent = function (text: string, style?: Partial<ResolvedTextStyle>): void {
|
|
45
|
+
if (!engine.textLayout) {
|
|
46
|
+
throw new Error("No TextLayoutService. Add a text plugin (withMonospace, withPretext, withTestMeasurer).")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
textContent = text
|
|
50
|
+
const resolved: ResolvedTextStyle = { ...DEFAULT_TEXT_STYLE, ...style }
|
|
51
|
+
prepared = engine.textLayout.prepare({ text, style: resolved })
|
|
52
|
+
|
|
53
|
+
// Install a MeasureFunc via original (bypasses text-clearing override)
|
|
54
|
+
const p = prepared
|
|
55
|
+
origSetMeasure((width, widthMode) => {
|
|
56
|
+
if (widthMode === MEASURE_MODE_UNDEFINED) {
|
|
57
|
+
const sizes = p.intrinsicSizes()
|
|
58
|
+
const result = p.layout({ maxWidth: sizes.maxContentWidth })
|
|
59
|
+
return { width: result.width, height: result.height }
|
|
60
|
+
}
|
|
61
|
+
const result = p.layout({ maxWidth: width })
|
|
62
|
+
const usedWidth = widthMode === MEASURE_MODE_AT_MOST ? Math.min(width, result.width) : width
|
|
63
|
+
return { width: usedWidth, height: result.height }
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
flexNode.getTextContent = function (): string | null {
|
|
68
|
+
return textContent
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Override setMeasureFunc/unsetMeasureFunc to clear text state when
|
|
72
|
+
// the user explicitly sets a custom measure function.
|
|
73
|
+
flexNode.setMeasureFunc = function (fn) {
|
|
74
|
+
textContent = null
|
|
75
|
+
prepared = null
|
|
76
|
+
origSetMeasure(fn)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
flexNode.unsetMeasureFunc = function () {
|
|
80
|
+
textContent = null
|
|
81
|
+
prepared = null
|
|
82
|
+
origUnsetMeasure()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return flexNode
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Engine
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/** The composable Flexily engine. */
|
|
93
|
+
export interface FlexilyEngine {
|
|
94
|
+
createNode(): FlexilyNode
|
|
95
|
+
calculateLayout(root: FlexilyNode, width?: number, height?: number, direction?: number): void
|
|
96
|
+
textLayout?: TextLayoutService
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A plugin that extends or configures the engine. */
|
|
100
|
+
export type FlexilyPlugin = (engine: FlexilyEngine) => FlexilyEngine
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a bare Flexily engine — no plugins, just nodes and layout.
|
|
104
|
+
* Add plugins via pipe() for text measurement.
|
|
105
|
+
*/
|
|
106
|
+
export function createBareFlexily(): FlexilyEngine {
|
|
107
|
+
const engine: FlexilyEngine = {
|
|
108
|
+
createNode(): FlexilyNode {
|
|
109
|
+
return mixTextContent(Node.create(), engine)
|
|
110
|
+
},
|
|
111
|
+
calculateLayout(root: FlexilyNode, width?: number, height?: number, direction?: number): void {
|
|
112
|
+
root.calculateLayout(width, height, direction ?? DIRECTION_LTR)
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
return engine
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Apply plugins to an engine, left to right.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const flex = pipe(createBareFlexily(), withMonospace(), withTestMeasurer())
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function pipe(engine: FlexilyEngine, ...plugins: FlexilyPlugin[]): FlexilyEngine {
|
|
127
|
+
let result = engine
|
|
128
|
+
for (const plugin of plugins) {
|
|
129
|
+
result = plugin(result)
|
|
130
|
+
}
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a batteries-included Flexily engine.
|
|
136
|
+
*
|
|
137
|
+
* Includes monospace text measurement (1 char = charWidth units).
|
|
138
|
+
* For terminal UIs, the default charWidth/charHeight of 1 maps to terminal cells.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* import { createFlexily } from "flexily"
|
|
143
|
+
* const flex = createFlexily()
|
|
144
|
+
* const node = flex.createNode()
|
|
145
|
+
* node.setTextContent("Hello world")
|
|
146
|
+
* flex.calculateLayout(node, 80, 24)
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export function createFlexily(options?: { charWidth?: number; charHeight?: number }): FlexilyEngine {
|
|
150
|
+
const engine = createBareFlexily()
|
|
151
|
+
engine.textLayout = createMonospaceMeasurer(options?.charWidth ?? 1, options?.charHeight ?? 1)
|
|
152
|
+
return engine
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,26 +4,49 @@
|
|
|
4
4
|
* A Yoga-compatible layout engine for terminal UIs and other environments
|
|
5
5
|
* where WebAssembly is not available or desirable.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* ```typescript
|
|
9
|
-
* import { Node, FLEX_DIRECTION_ROW, DIRECTION_LTR } from 'flexily';
|
|
10
|
-
*
|
|
11
|
-
* const root = Node.create();
|
|
12
|
-
* root.setWidth(80);
|
|
13
|
-
* root.setHeight(24);
|
|
14
|
-
* root.setFlexDirection(FLEX_DIRECTION_ROW);
|
|
15
|
-
*
|
|
16
|
-
* const child = Node.create();
|
|
17
|
-
* child.setFlexGrow(1);
|
|
18
|
-
* root.insertChild(child, 0);
|
|
7
|
+
* Two API levels:
|
|
19
8
|
*
|
|
20
|
-
*
|
|
9
|
+
* 1. Composable (recommended):
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createFlexily } from "flexily"
|
|
12
|
+
* const flex = createFlexily()
|
|
13
|
+
* const node = flex.createNode()
|
|
14
|
+
* node.setTextContent("Hello world")
|
|
15
|
+
* flex.calculateLayout(node, 80, 24)
|
|
16
|
+
* ```
|
|
21
17
|
*
|
|
22
|
-
*
|
|
18
|
+
* 2. Low-level (Yoga-compatible):
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { Node, DIRECTION_LTR } from "flexily"
|
|
21
|
+
* const root = Node.create()
|
|
22
|
+
* root.setWidth(80)
|
|
23
|
+
* root.calculateLayout(80, 24, DIRECTION_LTR)
|
|
23
24
|
* ```
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
+
// Composable API
|
|
28
|
+
export { createFlexily, createBareFlexily, pipe } from "./create-flexily.js"
|
|
29
|
+
export type { FlexilyEngine, FlexilyNode, FlexilyPlugin } from "./create-flexily.js"
|
|
30
|
+
|
|
31
|
+
// Text measurement plugins
|
|
32
|
+
export { createMonospaceMeasurer, withMonospace } from "./monospace-measurer.js"
|
|
33
|
+
export { createTestMeasurer, withTestMeasurer } from "./test-measurer.js"
|
|
34
|
+
export { createPretextMeasurer, withPretext } from "./pretext-measurer.js"
|
|
35
|
+
export type { PretextAPI, PretextPrepared, PretextLayout } from "./pretext-measurer.js"
|
|
36
|
+
|
|
37
|
+
// Text layout service types
|
|
38
|
+
export type {
|
|
39
|
+
TextLayoutService,
|
|
40
|
+
PreparedText,
|
|
41
|
+
TextLayout,
|
|
42
|
+
TextLine,
|
|
43
|
+
TextConstraints,
|
|
44
|
+
IntrinsicSizes,
|
|
45
|
+
ResolvedTextStyle,
|
|
46
|
+
TextPrepareInput,
|
|
47
|
+
} from "./text-layout.js"
|
|
48
|
+
|
|
49
|
+
// Node class (zero-alloc version) — low-level Yoga-compatible API
|
|
27
50
|
export { Node } from "./node-zero.js"
|
|
28
51
|
|
|
29
52
|
// All constants (Yoga-compatible)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monospace text measurement.
|
|
3
|
+
*
|
|
4
|
+
* Terminal text: graphemeCount * charWidth, always 1 line (no wrapping).
|
|
5
|
+
* This is the default for terminal UIs where 1 char = 1 cell.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FlexilyPlugin } from "./create-flexily.js"
|
|
9
|
+
import type { TextLayoutService, PreparedText, TextLayout, IntrinsicSizes } from "./text-layout.js"
|
|
10
|
+
|
|
11
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a monospace text measurement service.
|
|
15
|
+
*
|
|
16
|
+
* @param charWidth - Width of each character cell (default: 1 for terminal grids)
|
|
17
|
+
* @param charHeight - Height of each character cell (default: 1 for terminal grids)
|
|
18
|
+
*/
|
|
19
|
+
export function createMonospaceMeasurer(charWidth = 1, charHeight = 1): TextLayoutService {
|
|
20
|
+
return {
|
|
21
|
+
prepare(input) {
|
|
22
|
+
const { text } = input
|
|
23
|
+
|
|
24
|
+
// Count grapheme clusters for proper emoji/CJK support
|
|
25
|
+
let graphemeCount = 0
|
|
26
|
+
for (const _ of segmenter.segment(text)) graphemeCount++
|
|
27
|
+
|
|
28
|
+
const totalWidth = graphemeCount * charWidth
|
|
29
|
+
|
|
30
|
+
const prepared: PreparedText = {
|
|
31
|
+
intrinsicSizes(): IntrinsicSizes {
|
|
32
|
+
return {
|
|
33
|
+
minContentWidth: totalWidth, // monospace: entire text is one unbreakable segment
|
|
34
|
+
maxContentWidth: totalWidth,
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
layout(constraints): TextLayout {
|
|
39
|
+
const width = constraints.maxWidth !== undefined ? Math.min(totalWidth, constraints.maxWidth) : totalWidth
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
width,
|
|
43
|
+
height: charHeight,
|
|
44
|
+
lineCount: 1,
|
|
45
|
+
firstBaseline: charHeight,
|
|
46
|
+
lastBaseline: charHeight,
|
|
47
|
+
truncated: width < totalWidth,
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return prepared
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Plugin: add monospace text measurement to the engine.
|
|
59
|
+
*
|
|
60
|
+
* @param charWidth - Width per character cell (default: 1)
|
|
61
|
+
* @param charHeight - Height per character cell (default: 1)
|
|
62
|
+
*/
|
|
63
|
+
export function withMonospace(charWidth = 1, charHeight = 1): FlexilyPlugin {
|
|
64
|
+
return (engine) => {
|
|
65
|
+
engine.textLayout = createMonospaceMeasurer(charWidth, charHeight)
|
|
66
|
+
return engine
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|