flexily 0.3.2 → 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 CHANGED
@@ -5,6 +5,31 @@
5
5
  [![npm version](https://img.shields.io/npm/v/flexily.svg)](https://www.npmjs.com/package/flexily)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
- 1495 tests, including 44 Yoga compatibility tests and 1200+ incremental re-layout fuzz tests. Used by [silvery](https://silvery.dev) as its default layout engine.
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 # Main export
188
- ├── node-zero.ts # Node class with FlexInfo
189
- ├── layout-zero.ts # Layout algorithm (~2000 lines)
190
- ├── constants.ts # Flexbox constants (Yoga-compatible)
191
- ├── types.ts # TypeScript interfaces
192
- ├── utils.ts # Shared utilities
193
- └── classic/ # Allocating algorithm (for debugging)
194
- ├── node.ts
195
- └── layout.ts
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.3.2",
4
- "description": "Pure JavaScript flexbox layout engine — Yoga-compatible API, faster initial layout, smaller bundle, no WASM",
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",
@@ -52,6 +52,7 @@
52
52
  "test:watch": "bun test --watch",
53
53
  "bench": "bunx --bun vitest bench",
54
54
  "typecheck": "tsc --noEmit",
55
+ "ci": "bun run typecheck && bun test",
55
56
  "docs:dev": "vitepress dev docs",
56
57
  "docs:build": "vitepress build docs",
57
58
  "docs:preview": "vitepress preview docs"
@@ -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
- * @example
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
- * root.calculateLayout(80, 24, DIRECTION_LTR);
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
- * console.log(child.getComputedWidth()); // 80
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
- // Node class (zero-alloc version)
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
+ }