@effect-tui/react 0.13.0 → 0.14.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 +11 -7
- package/dist/jsx-runtime.d.ts +1 -2
- package/dist/jsx-runtime.d.ts.map +1 -1
- package/dist/src/components/Markdown.js +7 -7
- package/dist/src/components/Markdown.js.map +1 -1
- package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
- package/dist/src/components/MultilineTextInput.js +11 -0
- package/dist/src/components/MultilineTextInput.js.map +1 -1
- package/dist/src/components/TextInput.d.ts.map +1 -1
- package/dist/src/components/TextInput.js +15 -0
- package/dist/src/components/TextInput.js.map +1 -1
- package/dist/src/hosts/base.d.ts +16 -0
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +30 -0
- package/dist/src/hosts/base.js.map +1 -1
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +7 -8
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/canvas.d.ts.map +1 -1
- package/dist/src/hosts/canvas.js +5 -3
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/hosts/codeblock.d.ts.map +1 -1
- package/dist/src/hosts/codeblock.js +5 -4
- package/dist/src/hosts/codeblock.js.map +1 -1
- package/dist/src/hosts/flex-container.d.ts.map +1 -1
- package/dist/src/hosts/flex-container.js +5 -8
- package/dist/src/hosts/flex-container.js.map +1 -1
- package/dist/src/hosts/index.d.ts +1 -1
- package/dist/src/hosts/index.d.ts.map +1 -1
- package/dist/src/hosts/index.js +2 -3
- package/dist/src/hosts/index.js.map +1 -1
- package/dist/src/hosts/overlay-item.js +2 -2
- package/dist/src/hosts/overlay-item.js.map +1 -1
- package/dist/src/hosts/overlay.d.ts.map +1 -1
- package/dist/src/hosts/overlay.js +6 -11
- package/dist/src/hosts/overlay.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +4 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +32 -24
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/spacer.d.ts.map +1 -1
- package/dist/src/hosts/spacer.js +1 -3
- package/dist/src/hosts/spacer.js.map +1 -1
- package/dist/src/hosts/text.d.ts +24 -45
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +69 -215
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/hosts/zstack.d.ts.map +1 -1
- package/dist/src/hosts/zstack.js +4 -10
- package/dist/src/hosts/zstack.js.map +1 -1
- package/dist/src/reconciler/types.d.ts +2 -0
- package/dist/src/reconciler/types.d.ts.map +1 -1
- package/dist/src/renderer/core/FrameBuilder.d.ts.map +1 -1
- package/dist/src/renderer/core/FrameBuilder.js +2 -0
- package/dist/src/renderer/core/FrameBuilder.js.map +1 -1
- package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.js +5 -2
- package/dist/src/renderer/input/InputProcessor.js.map +1 -1
- package/dist/src/renderer/lifecycle/RenderCache.d.ts +3 -0
- package/dist/src/renderer/lifecycle/RenderCache.d.ts.map +1 -0
- package/dist/src/renderer/lifecycle/RenderCache.js +9 -0
- package/dist/src/renderer/lifecycle/RenderCache.js.map +1 -0
- package/dist/src/renderer/lifecycle/index.d.ts +1 -0
- package/dist/src/renderer/lifecycle/index.d.ts.map +1 -1
- package/dist/src/renderer/lifecycle/index.js +1 -0
- package/dist/src/renderer/lifecycle/index.js.map +1 -1
- package/dist/src/renderer-types.d.ts +1 -1
- package/dist/src/renderer-types.d.ts.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +4 -14
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/test/render-tui.d.ts.map +1 -1
- package/dist/src/test/render-tui.js +1 -0
- package/dist/src/test/render-tui.js.map +1 -1
- package/dist/src/utils/alignment.d.ts +4 -0
- package/dist/src/utils/alignment.d.ts.map +1 -1
- package/dist/src/utils/alignment.js +12 -0
- package/dist/src/utils/alignment.js.map +1 -1
- package/dist/src/utils/index.d.ts +3 -2
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +3 -2
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/styles.d.ts +6 -1
- package/dist/src/utils/styles.d.ts.map +1 -1
- package/dist/src/utils/styles.js +9 -0
- package/dist/src/utils/styles.js.map +1 -1
- package/dist/src/utils/text-wrap.d.ts +10 -0
- package/dist/src/utils/text-wrap.d.ts.map +1 -0
- package/dist/src/utils/text-wrap.js +64 -0
- package/dist/src/utils/text-wrap.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/jsx-runtime.ts +1 -2
- package/package.json +2 -2
- package/src/components/Markdown.tsx +7 -7
- package/src/components/MultilineTextInput.tsx +14 -0
- package/src/components/TextInput.tsx +18 -0
- package/src/hosts/base.ts +35 -0
- package/src/hosts/box.ts +7 -8
- package/src/hosts/canvas.ts +5 -3
- package/src/hosts/codeblock.ts +5 -4
- package/src/hosts/flex-container.ts +5 -7
- package/src/hosts/index.ts +1 -4
- package/src/hosts/overlay-item.ts +2 -2
- package/src/hosts/overlay.ts +6 -12
- package/src/hosts/scroll.ts +34 -24
- package/src/hosts/spacer.ts +1 -3
- package/src/hosts/text.ts +89 -256
- package/src/hosts/zstack.ts +4 -11
- package/src/reconciler/types.ts +3 -0
- package/src/renderer/core/FrameBuilder.ts +3 -0
- package/src/renderer/input/InputProcessor.ts +5 -2
- package/src/renderer/lifecycle/RenderCache.ts +13 -0
- package/src/renderer/lifecycle/index.ts +1 -0
- package/src/renderer-types.ts +1 -1
- package/src/renderer.ts +6 -22
- package/src/test/render-tui.ts +1 -0
- package/src/utils/alignment.ts +18 -0
- package/src/utils/index.ts +3 -1
- package/src/utils/styles.ts +18 -1
- package/src/utils/text-wrap.ts +66 -0
package/src/hosts/text.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { type CellBuffer, type Color, displayWidth, type Palette } from "@effect-tui/core"
|
|
2
2
|
import type { ColorMotionValue } from "../motion/color-motion-value.js"
|
|
3
|
-
import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
|
|
4
|
-
import { resolveInheritedBgStyle, styleIdFromProps } from "../utils/index.js"
|
|
3
|
+
import type { CommonProps, HostContext, HostInstance, Rect, Size } from "../reconciler/types.js"
|
|
4
|
+
import { resolveInheritedBgStyle, styleIdFromProps, wrapSpans } from "../utils/index.js"
|
|
5
5
|
import { BaseHost, getInheritedBg } from "./base.js"
|
|
6
6
|
|
|
7
7
|
/** Color prop that can be a static Color or a spring-animated ColorMotionValue */
|
|
8
8
|
export type ColorProp = Color | ColorMotionValue
|
|
9
9
|
|
|
10
|
+
/** A span of text with optional styling */
|
|
11
|
+
export interface StyledSpan {
|
|
12
|
+
text: string
|
|
13
|
+
fg?: Color
|
|
14
|
+
bg?: Color
|
|
15
|
+
bold?: boolean
|
|
16
|
+
dimmed?: boolean
|
|
17
|
+
italic?: boolean
|
|
18
|
+
underline?: boolean
|
|
19
|
+
strikethrough?: boolean
|
|
20
|
+
inverse?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
export interface TextProps extends CommonProps {
|
|
11
24
|
fg?: ColorProp
|
|
12
25
|
bg?: ColorProp
|
|
@@ -18,6 +31,8 @@ export interface TextProps extends CommonProps {
|
|
|
18
31
|
inverse?: boolean
|
|
19
32
|
/** If true, wrap text to multiple lines (default: false, text is truncated) */
|
|
20
33
|
wrap?: boolean
|
|
34
|
+
/** Optional styled spans (bypasses child parsing) */
|
|
35
|
+
spans?: StyledSpan[]
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
export class TextHost extends BaseHost {
|
|
@@ -38,7 +53,10 @@ export class TextHost extends BaseHost {
|
|
|
38
53
|
private cachedContent: string | null = null
|
|
39
54
|
// Cache for styled mode
|
|
40
55
|
private cachedStyledLines: StyledSpan[][] | null = null
|
|
56
|
+
private cachedSpans: StyledSpan[] | null = null
|
|
41
57
|
private hasSpans = false
|
|
58
|
+
private explicitSpans: StyledSpan[] | null = null
|
|
59
|
+
private prepared = false
|
|
42
60
|
|
|
43
61
|
constructor(props: TextProps, ctx: HostContext) {
|
|
44
62
|
super("text", props, ctx)
|
|
@@ -109,64 +127,24 @@ export class TextHost extends BaseHost {
|
|
|
109
127
|
return spans
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
for (const token of tokens) {
|
|
122
|
-
if (!token) continue
|
|
123
|
-
const tokenWidth = displayWidth(token)
|
|
124
|
-
const isWhitespace = /^\s+$/.test(token)
|
|
125
|
-
|
|
126
|
-
if (lineWidth + tokenWidth <= maxWidth) {
|
|
127
|
-
// Token fits on current line
|
|
128
|
-
lines[lines.length - 1].push({ ...span, text: token })
|
|
129
|
-
lineWidth += tokenWidth
|
|
130
|
-
} else if (isWhitespace) {
|
|
131
|
-
// Skip whitespace at line break
|
|
132
|
-
continue
|
|
133
|
-
} else if (tokenWidth <= maxWidth) {
|
|
134
|
-
// Start new line with this token
|
|
135
|
-
lines.push([{ ...span, text: token }])
|
|
136
|
-
lineWidth = tokenWidth
|
|
137
|
-
} else {
|
|
138
|
-
// Token is longer than maxWidth - break by character
|
|
139
|
-
let charLine = ""
|
|
140
|
-
let charLineWidth = 0
|
|
141
|
-
for (const ch of token) {
|
|
142
|
-
const chWidth = displayWidth(ch)
|
|
143
|
-
if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
|
|
144
|
-
if (charLine) {
|
|
145
|
-
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
146
|
-
}
|
|
147
|
-
lines.push([])
|
|
148
|
-
lineWidth = 0
|
|
149
|
-
charLine = ch
|
|
150
|
-
charLineWidth = chWidth
|
|
151
|
-
} else {
|
|
152
|
-
charLine += ch
|
|
153
|
-
charLineWidth += chWidth
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (charLine) {
|
|
157
|
-
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
158
|
-
lineWidth += charLineWidth
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
130
|
+
private prepareContent(): void {
|
|
131
|
+
this.invalidateContent()
|
|
132
|
+
const useExplicitSpans = this.explicitSpans !== null
|
|
133
|
+
if (useExplicitSpans) {
|
|
134
|
+
this.hasSpans = false
|
|
135
|
+
this.cachedSpans = null
|
|
136
|
+
this.prepared = true
|
|
137
|
+
return
|
|
162
138
|
}
|
|
163
139
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
140
|
+
this.hasSpans = this.checkForSpans()
|
|
141
|
+
this.cachedSpans = this.hasSpans ? this.collectSpans() : null
|
|
142
|
+
this.prepared = true
|
|
143
|
+
}
|
|
168
144
|
|
|
169
|
-
|
|
145
|
+
private ensurePrepared(): void {
|
|
146
|
+
if (this.prepared) return
|
|
147
|
+
this.prepareContent()
|
|
170
148
|
}
|
|
171
149
|
|
|
172
150
|
/** Invalidate content cache when children change */
|
|
@@ -174,29 +152,50 @@ export class TextHost extends BaseHost {
|
|
|
174
152
|
this.cachedContent = null
|
|
175
153
|
this.cachedLines = null
|
|
176
154
|
this.cachedStyledLines = null
|
|
155
|
+
this.cachedSpans = null
|
|
156
|
+
this.prepared = false
|
|
177
157
|
}
|
|
178
158
|
|
|
179
|
-
|
|
180
|
-
|
|
159
|
+
protected override prepareSelf(): void {
|
|
160
|
+
this.prepareContent()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override appendChild(child: HostInstance): void {
|
|
164
|
+
super.appendChild(child)
|
|
181
165
|
this.invalidateContent()
|
|
182
|
-
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
override removeChild(child: HostInstance): void {
|
|
169
|
+
super.removeChild(child)
|
|
170
|
+
this.invalidateContent()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override insertBefore(child: HostInstance, before: HostInstance): void {
|
|
174
|
+
super.insertBefore(child, before)
|
|
175
|
+
this.invalidateContent()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
measure(maxW: number, maxH: number): Size {
|
|
179
|
+
const constrained = this.constrainProposal(maxW, maxH)
|
|
180
|
+
this.ensurePrepared()
|
|
181
|
+
const useExplicitSpans = this.explicitSpans !== null
|
|
183
182
|
|
|
184
183
|
// Styled mode: use span-aware rendering
|
|
185
|
-
if (this.hasSpans) {
|
|
186
|
-
const spans = this.collectSpans()
|
|
184
|
+
if (useExplicitSpans || this.hasSpans) {
|
|
185
|
+
const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
|
|
187
186
|
if (this.wrap) {
|
|
188
|
-
this.cachedStyledLines =
|
|
189
|
-
this.cachedWidth =
|
|
190
|
-
const h = Math.min(this.cachedStyledLines.length,
|
|
187
|
+
this.cachedStyledLines = wrapSpans(spans, constrained.w)
|
|
188
|
+
this.cachedWidth = constrained.w
|
|
189
|
+
const h = Math.min(this.cachedStyledLines.length, constrained.h)
|
|
191
190
|
const w = this.cachedStyledLines.reduce(
|
|
192
191
|
(max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
|
|
193
192
|
0,
|
|
194
193
|
)
|
|
195
|
-
return { w, h }
|
|
194
|
+
return this.constrainResult({ w, h })
|
|
196
195
|
}
|
|
197
196
|
// Non-wrap styled mode
|
|
198
197
|
const totalWidth = spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
|
|
199
|
-
return { w: Math.min(totalWidth,
|
|
198
|
+
return this.constrainResult({ w: Math.min(totalWidth, constrained.w), h: 1 })
|
|
200
199
|
}
|
|
201
200
|
|
|
202
201
|
// Simple mode: single style for all content
|
|
@@ -206,19 +205,19 @@ export class TextHost extends BaseHost {
|
|
|
206
205
|
if (this.wrap) {
|
|
207
206
|
// Wrap mode: may span multiple lines. Cache result for render()
|
|
208
207
|
this.cachedLines = rawLines.flatMap((line, idx) =>
|
|
209
|
-
idx < rawLines.length - 1 ? [...this.wrapText(line,
|
|
208
|
+
idx < rawLines.length - 1 ? [...this.wrapText(line, constrained.w), ""] : this.wrapText(line, constrained.w),
|
|
210
209
|
)
|
|
211
|
-
this.cachedWidth =
|
|
210
|
+
this.cachedWidth = constrained.w
|
|
212
211
|
const w = this.cachedLines.reduce((max, line) => Math.max(max, displayWidth(line)), 0)
|
|
213
|
-
return { w, h: Math.min(this.cachedLines.length,
|
|
212
|
+
return this.constrainResult({ w, h: Math.min(this.cachedLines.length, constrained.h) })
|
|
214
213
|
}
|
|
215
214
|
|
|
216
215
|
// Default: respect explicit newlines but do not wrap long words
|
|
217
216
|
this.cachedLines = null
|
|
218
|
-
const widths = rawLines.map((line) => Math.min(displayWidth(line),
|
|
217
|
+
const widths = rawLines.map((line) => Math.min(displayWidth(line), constrained.w))
|
|
219
218
|
const w = widths.reduce((max, val) => Math.max(max, val), 0)
|
|
220
|
-
const h = Math.min(rawLines.length,
|
|
221
|
-
return { w, h }
|
|
219
|
+
const h = Math.min(rawLines.length, constrained.h)
|
|
220
|
+
return this.constrainResult({ w, h })
|
|
222
221
|
}
|
|
223
222
|
|
|
224
223
|
/** Wrap text to fit within maxWidth, preferring word boundaries */
|
|
@@ -276,29 +275,34 @@ export class TextHost extends BaseHost {
|
|
|
276
275
|
}
|
|
277
276
|
|
|
278
277
|
override layout(rect: Rect): void {
|
|
279
|
-
|
|
278
|
+
const layoutRect = this.layoutWithConstraints(rect)
|
|
280
279
|
// Layout children (RawTextHost nodes) at same position
|
|
281
280
|
for (const child of this.children) {
|
|
282
|
-
child.layout(
|
|
281
|
+
child.layout(layoutRect)
|
|
283
282
|
}
|
|
284
283
|
}
|
|
285
284
|
|
|
286
285
|
render(buffer: CellBuffer, palette: Palette): void {
|
|
287
|
-
if (!this.rect)
|
|
286
|
+
if (!this.rect) {
|
|
287
|
+
this.prepared = false
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
this.ensurePrepared()
|
|
288
291
|
|
|
289
292
|
// If text has no bg, inherit from parent box for proper highlight rendering
|
|
290
293
|
const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
|
|
291
294
|
const inheritedBg = this.bg ?? getInheritedBg(this.parent)
|
|
295
|
+
const useExplicitSpans = this.explicitSpans !== null
|
|
292
296
|
|
|
293
297
|
// Styled mode: render with per-span styles
|
|
294
|
-
if (this.hasSpans) {
|
|
295
|
-
const spans = this.collectSpans()
|
|
298
|
+
if (useExplicitSpans || this.hasSpans) {
|
|
296
299
|
const lines =
|
|
297
300
|
this.wrap && this.cachedStyledLines && this.cachedWidth === this.rect.w
|
|
298
301
|
? this.cachedStyledLines
|
|
299
|
-
:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
+
: (() => {
|
|
303
|
+
const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
|
|
304
|
+
return this.wrap ? wrapSpans(spans, this.rect.w) : [spans]
|
|
305
|
+
})()
|
|
302
306
|
|
|
303
307
|
for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
|
|
304
308
|
let x = this.rect.x
|
|
@@ -320,6 +324,7 @@ export class TextHost extends BaseHost {
|
|
|
320
324
|
x += displayWidth(span.text)
|
|
321
325
|
}
|
|
322
326
|
}
|
|
327
|
+
this.prepared = false
|
|
323
328
|
return
|
|
324
329
|
}
|
|
325
330
|
|
|
@@ -356,6 +361,7 @@ export class TextHost extends BaseHost {
|
|
|
356
361
|
const lineWidth = Math.min(displayWidth(lines[i]), this.rect.w)
|
|
357
362
|
buffer.drawText(this.rect.x, this.rect.y + i, lines[i], styleId, lineWidth)
|
|
358
363
|
}
|
|
364
|
+
this.prepared = false
|
|
359
365
|
return
|
|
360
366
|
}
|
|
361
367
|
|
|
@@ -369,6 +375,7 @@ export class TextHost extends BaseHost {
|
|
|
369
375
|
const lineWidth = Math.min(displayWidth(rawLines[i]), this.rect.w)
|
|
370
376
|
buffer.drawText(this.rect.x, this.rect.y + i, rawLines[i], styleId, lineWidth)
|
|
371
377
|
}
|
|
378
|
+
this.prepared = false
|
|
372
379
|
}
|
|
373
380
|
|
|
374
381
|
override updateProps(props: Record<string, unknown>): void {
|
|
@@ -387,6 +394,7 @@ export class TextHost extends BaseHost {
|
|
|
387
394
|
this.strikethrough = Boolean(props.strikethrough)
|
|
388
395
|
this.inverse = Boolean(props.inverse)
|
|
389
396
|
this.wrap = Boolean(props.wrap)
|
|
397
|
+
this.explicitSpans = "spans" in props ? ((props.spans as StyledSpan[] | undefined) ?? []) : null
|
|
390
398
|
}
|
|
391
399
|
}
|
|
392
400
|
|
|
@@ -506,178 +514,3 @@ export class SpanHost extends BaseHost {
|
|
|
506
514
|
this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
|
|
507
515
|
}
|
|
508
516
|
}
|
|
509
|
-
|
|
510
|
-
// ============================================================================
|
|
511
|
-
// Styled Text Host - for inline formatted text that wraps as a unit
|
|
512
|
-
// ============================================================================
|
|
513
|
-
|
|
514
|
-
/** A span of text with optional styling */
|
|
515
|
-
export interface StyledSpan {
|
|
516
|
-
text: string
|
|
517
|
-
fg?: Color
|
|
518
|
-
bg?: Color
|
|
519
|
-
bold?: boolean
|
|
520
|
-
dimmed?: boolean
|
|
521
|
-
italic?: boolean
|
|
522
|
-
underline?: boolean
|
|
523
|
-
strikethrough?: boolean
|
|
524
|
-
inverse?: boolean
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
export interface StyledTextProps extends CommonProps {
|
|
528
|
-
/** Array of styled text spans */
|
|
529
|
-
spans: StyledSpan[]
|
|
530
|
-
/** Default text color */
|
|
531
|
-
fg?: Color
|
|
532
|
-
/** Default background color */
|
|
533
|
-
bg?: Color
|
|
534
|
-
/** If true, wrap text to multiple lines */
|
|
535
|
-
wrap?: boolean
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Host for rendering multiple styled spans that wrap as a unit.
|
|
540
|
-
* Unlike using hstack with multiple text elements, this properly
|
|
541
|
-
* wraps styled inline text across lines.
|
|
542
|
-
*/
|
|
543
|
-
export class StyledTextHost extends BaseHost {
|
|
544
|
-
spans: StyledSpan[] = []
|
|
545
|
-
fg?: Color
|
|
546
|
-
bg?: Color
|
|
547
|
-
wrap = false
|
|
548
|
-
|
|
549
|
-
// Cache wrapped lines - each line is an array of spans
|
|
550
|
-
private cachedLines: StyledSpan[][] | null = null
|
|
551
|
-
private cachedWidth = 0
|
|
552
|
-
|
|
553
|
-
constructor(props: StyledTextProps, ctx: HostContext) {
|
|
554
|
-
super("styledtext", props, ctx)
|
|
555
|
-
this.updateProps(props as unknown as Record<string, unknown>)
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
measure(maxW: number, maxH: number): Size {
|
|
559
|
-
if (this.wrap) {
|
|
560
|
-
this.cachedLines = this.wrapSpans(this.spans, maxW)
|
|
561
|
-
this.cachedWidth = maxW
|
|
562
|
-
const h = Math.min(this.cachedLines.length, maxH)
|
|
563
|
-
const w = this.cachedLines.reduce(
|
|
564
|
-
(max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
|
|
565
|
-
0,
|
|
566
|
-
)
|
|
567
|
-
return { w, h }
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Non-wrap mode: single line, may truncate
|
|
571
|
-
const totalWidth = this.spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
|
|
572
|
-
return { w: Math.min(totalWidth, maxW), h: 1 }
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/** Wrap spans into lines, breaking at word boundaries */
|
|
576
|
-
private wrapSpans(spans: StyledSpan[], maxWidth: number): StyledSpan[][] {
|
|
577
|
-
const lines: StyledSpan[][] = [[]]
|
|
578
|
-
let lineWidth = 0
|
|
579
|
-
|
|
580
|
-
for (const span of spans) {
|
|
581
|
-
// Split span text into words (keeping whitespace as separate tokens)
|
|
582
|
-
const tokens = span.text.split(/(\s+)/)
|
|
583
|
-
|
|
584
|
-
for (const token of tokens) {
|
|
585
|
-
if (!token) continue
|
|
586
|
-
const tokenWidth = displayWidth(token)
|
|
587
|
-
const isWhitespace = /^\s+$/.test(token)
|
|
588
|
-
|
|
589
|
-
if (lineWidth + tokenWidth <= maxWidth) {
|
|
590
|
-
// Token fits on current line
|
|
591
|
-
lines[lines.length - 1].push({ ...span, text: token })
|
|
592
|
-
lineWidth += tokenWidth
|
|
593
|
-
} else if (isWhitespace) {
|
|
594
|
-
// Skip whitespace at line break
|
|
595
|
-
continue
|
|
596
|
-
} else if (tokenWidth <= maxWidth) {
|
|
597
|
-
// Start new line with this token
|
|
598
|
-
lines.push([{ ...span, text: token }])
|
|
599
|
-
lineWidth = tokenWidth
|
|
600
|
-
} else {
|
|
601
|
-
// Token is longer than maxWidth - break by character
|
|
602
|
-
let charLine = ""
|
|
603
|
-
let charLineWidth = 0
|
|
604
|
-
for (const ch of token) {
|
|
605
|
-
const chWidth = displayWidth(ch)
|
|
606
|
-
if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
|
|
607
|
-
if (charLine) {
|
|
608
|
-
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
609
|
-
}
|
|
610
|
-
lines.push([])
|
|
611
|
-
lineWidth = 0
|
|
612
|
-
charLine = ch
|
|
613
|
-
charLineWidth = chWidth
|
|
614
|
-
} else {
|
|
615
|
-
charLine += ch
|
|
616
|
-
charLineWidth += chWidth
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
if (charLine) {
|
|
620
|
-
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
621
|
-
lineWidth += charLineWidth
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Remove empty lines at the end
|
|
628
|
-
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
|
|
629
|
-
lines.pop()
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
return lines.length > 0 ? lines : [[]]
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
override layout(rect: Rect): void {
|
|
636
|
-
super.layout(rect)
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
render(buffer: CellBuffer, palette: Palette): void {
|
|
640
|
-
if (!this.rect) return
|
|
641
|
-
|
|
642
|
-
const inheritedBg = this.bg ?? getInheritedBg(this.parent)
|
|
643
|
-
|
|
644
|
-
// Get lines to render
|
|
645
|
-
const lines =
|
|
646
|
-
this.wrap && this.cachedLines && this.cachedWidth === this.rect.w
|
|
647
|
-
? this.cachedLines
|
|
648
|
-
: this.wrap
|
|
649
|
-
? this.wrapSpans(this.spans, this.rect.w)
|
|
650
|
-
: [this.spans]
|
|
651
|
-
|
|
652
|
-
for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
|
|
653
|
-
let x = this.rect.x
|
|
654
|
-
for (const span of lines[y]) {
|
|
655
|
-
const styleId = styleIdFromProps(palette, {
|
|
656
|
-
fg: span.fg ?? this.fg,
|
|
657
|
-
bg: span.bg ?? inheritedBg,
|
|
658
|
-
bold: span.bold,
|
|
659
|
-
dimmed: span.dimmed,
|
|
660
|
-
italic: span.italic,
|
|
661
|
-
underline: span.underline,
|
|
662
|
-
strikethrough: span.strikethrough,
|
|
663
|
-
inverse: span.inverse,
|
|
664
|
-
})
|
|
665
|
-
const availableWidth = this.rect.w - (x - this.rect.x)
|
|
666
|
-
if (availableWidth <= 0) break
|
|
667
|
-
const textWidth = Math.min(displayWidth(span.text), availableWidth)
|
|
668
|
-
buffer.drawText(x, this.rect.y + y, span.text, styleId, textWidth)
|
|
669
|
-
x += displayWidth(span.text)
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
override updateProps(props: Record<string, unknown>): void {
|
|
675
|
-
super.updateProps(props)
|
|
676
|
-
this.spans = (props.spans as StyledSpan[] | undefined) ?? []
|
|
677
|
-
this.fg = props.fg as Color | undefined
|
|
678
|
-
this.bg = props.bg as Color | undefined
|
|
679
|
-
this.wrap = Boolean(props.wrap)
|
|
680
|
-
// Invalidate cache when props change
|
|
681
|
-
this.cachedLines = null
|
|
682
|
-
}
|
|
683
|
-
}
|
package/src/hosts/zstack.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CellBuffer, Palette } from "@effect-tui/core"
|
|
2
2
|
import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
|
|
3
|
-
import {
|
|
3
|
+
import { alignedChildRect, type HAlign, type VAlign } from "../utils/index.js"
|
|
4
4
|
import { BaseHost } from "./base.js"
|
|
5
5
|
|
|
6
6
|
export interface ZStackProps extends CommonProps {
|
|
@@ -43,19 +43,12 @@ export class ZStackHost extends BaseHost {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
override layout(rect: Rect): void {
|
|
46
|
-
|
|
46
|
+
const layoutRect = this.layoutWithConstraints(rect)
|
|
47
47
|
|
|
48
48
|
for (let i = 0; i < this.children.length; i++) {
|
|
49
49
|
const child = this.children[i]
|
|
50
|
-
const size = this.cachedSizes[i] ?? child.measure(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
child.layout({
|
|
54
|
-
x,
|
|
55
|
-
y,
|
|
56
|
-
w: Math.min(rect.w, size.w),
|
|
57
|
-
h: Math.min(rect.h, size.h),
|
|
58
|
-
})
|
|
50
|
+
const size = this.cachedSizes[i] ?? child.measure(layoutRect.w, layoutRect.h)
|
|
51
|
+
child.layout(alignedChildRect(layoutRect, size, this.alignmentH, this.alignmentV))
|
|
59
52
|
}
|
|
60
53
|
}
|
|
61
54
|
|
package/src/reconciler/types.ts
CHANGED
|
@@ -29,6 +29,9 @@ export interface HostInstance {
|
|
|
29
29
|
/** Render to buffer */
|
|
30
30
|
render(buffer: CellBuffer, palette: Palette): void
|
|
31
31
|
|
|
32
|
+
/** Optional pre-frame hook for cache prep (called before measure/layout/render). */
|
|
33
|
+
prepareFrame?(): void
|
|
34
|
+
|
|
32
35
|
/** Update props from React */
|
|
33
36
|
updateProps(props: Record<string, unknown>): void
|
|
34
37
|
|
|
@@ -18,6 +18,9 @@ export class FrameBuilder {
|
|
|
18
18
|
* Returns timing information for each phase.
|
|
19
19
|
*/
|
|
20
20
|
build(root: HostInstance, buffer: CellBuffer, palette: Palette, width: number, height: number): FrameTimings {
|
|
21
|
+
// Pre-frame cache prep (optional)
|
|
22
|
+
root.prepareFrame?.()
|
|
23
|
+
|
|
21
24
|
// Clear buffer
|
|
22
25
|
let t = Prof.startPhase()
|
|
23
26
|
const clearStart = performance.now()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ANSI, decodeInput, type KeyMsg, type MouseMsg } from "@effect-tui/core"
|
|
2
|
+
import { requestExit } from "../../exit.js"
|
|
2
3
|
|
|
3
4
|
export interface InputProcessorConfig {
|
|
4
5
|
exitOnCtrlC: boolean
|
|
@@ -33,7 +34,9 @@ export class InputProcessor {
|
|
|
33
34
|
const endIdx = chunk.indexOf(ANSI.paste.endMarker)
|
|
34
35
|
if (endIdx >= 0) {
|
|
35
36
|
this.pasteBuffer += chunk.slice(0, endIdx)
|
|
36
|
-
this.config.
|
|
37
|
+
this.config.flushSync(() => {
|
|
38
|
+
this.config.dispatchPaste(this.pasteBuffer)
|
|
39
|
+
})
|
|
37
40
|
this.pasteBuffer = ""
|
|
38
41
|
this.pasteActive = false
|
|
39
42
|
chunk = chunk.slice(endIdx + ANSI.paste.endMarker.length)
|
|
@@ -104,7 +107,7 @@ export class InputProcessor {
|
|
|
104
107
|
if (this.config.onQuit) {
|
|
105
108
|
this.config.onQuit()
|
|
106
109
|
} else {
|
|
107
|
-
|
|
110
|
+
requestExit(0)
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type RenderCache<T> = Map<string, T>
|
|
2
|
+
|
|
3
|
+
const getGlobalCache = (): Map<string, unknown> => {
|
|
4
|
+
const globalAny = globalThis as typeof globalThis & {
|
|
5
|
+
__effectTuiRenderCache?: Map<string, unknown>
|
|
6
|
+
}
|
|
7
|
+
if (!globalAny.__effectTuiRenderCache) {
|
|
8
|
+
globalAny.__effectTuiRenderCache = new Map()
|
|
9
|
+
}
|
|
10
|
+
return globalAny.__effectTuiRenderCache
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const getRenderCache = <T>(): RenderCache<T> => getGlobalCache() as RenderCache<T>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { EventBus } from "./EventBus.js"
|
|
2
|
+
export { getRenderCache, type RenderCache } from "./RenderCache.js"
|
|
2
3
|
export { ResizeManager, type ResizeResult, type ResizeState } from "./ResizeManager.js"
|
|
3
4
|
export { TerminalSetup, type TerminalSetupConfig } from "./TerminalSetup.js"
|
package/src/renderer-types.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface RendererOptions {
|
|
|
83
83
|
mode?: "fullscreen" | "inline"
|
|
84
84
|
/** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
|
|
85
85
|
exitOnCtrlC?: boolean
|
|
86
|
-
/** Handle SIGINT/SIGTERM and
|
|
86
|
+
/** Handle SIGINT/SIGTERM and process exit cleanup. Defaults to true. */
|
|
87
87
|
handleSignals?: boolean
|
|
88
88
|
/** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
|
|
89
89
|
exitOnSignal?: boolean
|
package/src/renderer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { HostContext } from "./reconciler/types.js"
|
|
|
11
11
|
// Extracted modules
|
|
12
12
|
import { FrameBuilder, RendererState } from "./renderer/core/index.js"
|
|
13
13
|
import { InputProcessor } from "./renderer/input/index.js"
|
|
14
|
-
import { EventBus, TerminalSetup } from "./renderer/lifecycle/index.js"
|
|
14
|
+
import { EventBus, getRenderCache, TerminalSetup } from "./renderer/lifecycle/index.js"
|
|
15
15
|
import { FullscreenRenderer, InlineRenderer, StaticContentRenderer } from "./renderer/modes/index.js"
|
|
16
16
|
import { RendererContext } from "./renderer-context.js"
|
|
17
17
|
import { startDevRuntime, type DevOptions } from "./dev.js"
|
|
@@ -370,11 +370,11 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
370
370
|
renderer.stop()
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
// Handle normal process exit (synchronous - runs before exit completes)
|
|
374
|
-
onExit = () => cleanup()
|
|
375
|
-
process.on("exit", onExit)
|
|
376
|
-
|
|
377
373
|
if (handleSignals) {
|
|
374
|
+
// Handle normal process exit (synchronous - runs before exit completes)
|
|
375
|
+
onExit = () => cleanup()
|
|
376
|
+
process.on("exit", onExit)
|
|
377
|
+
|
|
378
378
|
// Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
|
|
379
379
|
onSignal = (signal: NodeJS.Signals) => {
|
|
380
380
|
cleanup()
|
|
@@ -468,18 +468,6 @@ export type RenderOptions = RendererOptions &
|
|
|
468
468
|
importMeta: ImportMetaLike
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
type RenderCache = Map<string, RenderInstance>
|
|
472
|
-
|
|
473
|
-
const getRenderCache = (): RenderCache => {
|
|
474
|
-
const globalAny = globalThis as typeof globalThis & {
|
|
475
|
-
__effectTuiRenderCache?: RenderCache
|
|
476
|
-
}
|
|
477
|
-
if (!globalAny.__effectTuiRenderCache) {
|
|
478
|
-
globalAny.__effectTuiRenderCache = new Map()
|
|
479
|
-
}
|
|
480
|
-
return globalAny.__effectTuiRenderCache
|
|
481
|
-
}
|
|
482
|
-
|
|
483
471
|
const stripQuery = (url: string): string => url.split("?")[0]
|
|
484
472
|
|
|
485
473
|
const createRenderInstance = (
|
|
@@ -558,14 +546,10 @@ export function render(element: ReactNode, options?: RenderOptions): RenderInsta
|
|
|
558
546
|
}
|
|
559
547
|
|
|
560
548
|
const baseUrl = stripQuery(importMeta.url)
|
|
561
|
-
const renderCache = getRenderCache()
|
|
549
|
+
const renderCache = getRenderCache<RenderInstance>()
|
|
562
550
|
const cached = renderCache.get(baseUrl)
|
|
563
551
|
if (cached) return cached
|
|
564
552
|
|
|
565
|
-
if (!importMeta.main) {
|
|
566
|
-
throw new Error("[effect-tui] render(..., { dev: true }) must be called from the entry module")
|
|
567
|
-
}
|
|
568
|
-
|
|
569
553
|
const renderer = createRenderer(options)
|
|
570
554
|
const root = createRoot(renderer)
|
|
571
555
|
const entryPath = fileURLToPath(new URL(baseUrl))
|
package/src/test/render-tui.ts
CHANGED
package/src/utils/alignment.ts
CHANGED
|
@@ -48,3 +48,21 @@ export function alignInRect(
|
|
|
48
48
|
|
|
49
49
|
return { x, y }
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate a child rect aligned within a container rect, clamped to container size.
|
|
54
|
+
*/
|
|
55
|
+
export function alignedChildRect(
|
|
56
|
+
rect: Rect,
|
|
57
|
+
size: Size,
|
|
58
|
+
hAlign: HAlign = "center",
|
|
59
|
+
vAlign: VAlign = "center",
|
|
60
|
+
): Rect {
|
|
61
|
+
const { x, y } = alignInRect(rect, size, hAlign, vAlign)
|
|
62
|
+
return {
|
|
63
|
+
x,
|
|
64
|
+
y,
|
|
65
|
+
w: Math.min(rect.w, size.w),
|
|
66
|
+
h: Math.min(rect.h, size.h),
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { alignInRect, type HAlign, type VAlign } from "./alignment.js"
|
|
1
|
+
export { alignInRect, alignedChildRect, type HAlign, type VAlign } from "./alignment.js"
|
|
2
2
|
export {
|
|
3
3
|
type BorderChars,
|
|
4
4
|
type BorderKind,
|
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
export { type FlexAlignment, type FlexAxis, type FlexMeasureResult, layoutFlex, measureFlex } from "./flex-layout.js"
|
|
12
12
|
export { type Padding, type PaddingInput, resolvePadding } from "./padding.js"
|
|
13
13
|
export {
|
|
14
|
+
fillRectWithInheritedBg,
|
|
14
15
|
resolveBgStyle,
|
|
15
16
|
resolveInheritedBgStyle,
|
|
16
17
|
type StyleInput,
|
|
@@ -19,3 +20,4 @@ export {
|
|
|
19
20
|
styleSpecFromProps,
|
|
20
21
|
toColorValue,
|
|
21
22
|
} from "./styles.js"
|
|
23
|
+
export { wrapSpans } from "./text-wrap.js"
|