@effect-tui/react 0.13.0 → 0.14.1

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.
Files changed (120) hide show
  1. package/README.md +11 -7
  2. package/dist/jsx-runtime.d.ts +1 -2
  3. package/dist/jsx-runtime.d.ts.map +1 -1
  4. package/dist/src/components/Markdown.js +7 -7
  5. package/dist/src/components/Markdown.js.map +1 -1
  6. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  7. package/dist/src/components/MultilineTextInput.js +11 -0
  8. package/dist/src/components/MultilineTextInput.js.map +1 -1
  9. package/dist/src/components/TextInput.d.ts.map +1 -1
  10. package/dist/src/components/TextInput.js +15 -0
  11. package/dist/src/components/TextInput.js.map +1 -1
  12. package/dist/src/hosts/base.d.ts +16 -0
  13. package/dist/src/hosts/base.d.ts.map +1 -1
  14. package/dist/src/hosts/base.js +30 -0
  15. package/dist/src/hosts/base.js.map +1 -1
  16. package/dist/src/hosts/box.d.ts.map +1 -1
  17. package/dist/src/hosts/box.js +7 -8
  18. package/dist/src/hosts/box.js.map +1 -1
  19. package/dist/src/hosts/canvas.d.ts.map +1 -1
  20. package/dist/src/hosts/canvas.js +5 -3
  21. package/dist/src/hosts/canvas.js.map +1 -1
  22. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  23. package/dist/src/hosts/codeblock.js +5 -4
  24. package/dist/src/hosts/codeblock.js.map +1 -1
  25. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  26. package/dist/src/hosts/flex-container.js +5 -8
  27. package/dist/src/hosts/flex-container.js.map +1 -1
  28. package/dist/src/hosts/index.d.ts +1 -1
  29. package/dist/src/hosts/index.d.ts.map +1 -1
  30. package/dist/src/hosts/index.js +2 -3
  31. package/dist/src/hosts/index.js.map +1 -1
  32. package/dist/src/hosts/overlay-item.js +2 -2
  33. package/dist/src/hosts/overlay-item.js.map +1 -1
  34. package/dist/src/hosts/overlay.d.ts.map +1 -1
  35. package/dist/src/hosts/overlay.js +6 -11
  36. package/dist/src/hosts/overlay.js.map +1 -1
  37. package/dist/src/hosts/scroll.d.ts +4 -0
  38. package/dist/src/hosts/scroll.d.ts.map +1 -1
  39. package/dist/src/hosts/scroll.js +32 -24
  40. package/dist/src/hosts/scroll.js.map +1 -1
  41. package/dist/src/hosts/spacer.d.ts.map +1 -1
  42. package/dist/src/hosts/spacer.js +1 -3
  43. package/dist/src/hosts/spacer.js.map +1 -1
  44. package/dist/src/hosts/text.d.ts +24 -45
  45. package/dist/src/hosts/text.d.ts.map +1 -1
  46. package/dist/src/hosts/text.js +69 -215
  47. package/dist/src/hosts/text.js.map +1 -1
  48. package/dist/src/hosts/zstack.d.ts.map +1 -1
  49. package/dist/src/hosts/zstack.js +4 -10
  50. package/dist/src/hosts/zstack.js.map +1 -1
  51. package/dist/src/reconciler/types.d.ts +2 -0
  52. package/dist/src/reconciler/types.d.ts.map +1 -1
  53. package/dist/src/renderer/core/FrameBuilder.d.ts.map +1 -1
  54. package/dist/src/renderer/core/FrameBuilder.js +2 -0
  55. package/dist/src/renderer/core/FrameBuilder.js.map +1 -1
  56. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  57. package/dist/src/renderer/input/InputProcessor.js +5 -2
  58. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  59. package/dist/src/renderer/lifecycle/RenderCache.d.ts +3 -0
  60. package/dist/src/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  61. package/dist/src/renderer/lifecycle/RenderCache.js +9 -0
  62. package/dist/src/renderer/lifecycle/RenderCache.js.map +1 -0
  63. package/dist/src/renderer/lifecycle/index.d.ts +1 -0
  64. package/dist/src/renderer/lifecycle/index.d.ts.map +1 -1
  65. package/dist/src/renderer/lifecycle/index.js +1 -0
  66. package/dist/src/renderer/lifecycle/index.js.map +1 -1
  67. package/dist/src/renderer-types.d.ts +1 -1
  68. package/dist/src/renderer-types.d.ts.map +1 -1
  69. package/dist/src/renderer.d.ts.map +1 -1
  70. package/dist/src/renderer.js +4 -14
  71. package/dist/src/renderer.js.map +1 -1
  72. package/dist/src/test/render-tui.d.ts.map +1 -1
  73. package/dist/src/test/render-tui.js +1 -0
  74. package/dist/src/test/render-tui.js.map +1 -1
  75. package/dist/src/utils/alignment.d.ts +4 -0
  76. package/dist/src/utils/alignment.d.ts.map +1 -1
  77. package/dist/src/utils/alignment.js +12 -0
  78. package/dist/src/utils/alignment.js.map +1 -1
  79. package/dist/src/utils/index.d.ts +3 -2
  80. package/dist/src/utils/index.d.ts.map +1 -1
  81. package/dist/src/utils/index.js +3 -2
  82. package/dist/src/utils/index.js.map +1 -1
  83. package/dist/src/utils/styles.d.ts +6 -1
  84. package/dist/src/utils/styles.d.ts.map +1 -1
  85. package/dist/src/utils/styles.js +9 -0
  86. package/dist/src/utils/styles.js.map +1 -1
  87. package/dist/src/utils/text-wrap.d.ts +10 -0
  88. package/dist/src/utils/text-wrap.d.ts.map +1 -0
  89. package/dist/src/utils/text-wrap.js +64 -0
  90. package/dist/src/utils/text-wrap.js.map +1 -0
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/jsx-runtime.ts +1 -2
  93. package/package.json +2 -2
  94. package/src/components/Markdown.tsx +7 -7
  95. package/src/components/MultilineTextInput.tsx +14 -0
  96. package/src/components/TextInput.tsx +18 -0
  97. package/src/hosts/base.ts +35 -0
  98. package/src/hosts/box.ts +7 -8
  99. package/src/hosts/canvas.ts +5 -3
  100. package/src/hosts/codeblock.ts +5 -4
  101. package/src/hosts/flex-container.ts +5 -7
  102. package/src/hosts/index.ts +1 -4
  103. package/src/hosts/overlay-item.ts +2 -2
  104. package/src/hosts/overlay.ts +6 -12
  105. package/src/hosts/scroll.ts +34 -24
  106. package/src/hosts/spacer.ts +1 -3
  107. package/src/hosts/text.ts +89 -256
  108. package/src/hosts/zstack.ts +4 -11
  109. package/src/reconciler/types.ts +3 -0
  110. package/src/renderer/core/FrameBuilder.ts +3 -0
  111. package/src/renderer/input/InputProcessor.ts +5 -2
  112. package/src/renderer/lifecycle/RenderCache.ts +13 -0
  113. package/src/renderer/lifecycle/index.ts +1 -0
  114. package/src/renderer-types.ts +1 -1
  115. package/src/renderer.ts +6 -22
  116. package/src/test/render-tui.ts +1 -0
  117. package/src/utils/alignment.ts +18 -0
  118. package/src/utils/index.ts +3 -1
  119. package/src/utils/styles.ts +18 -1
  120. 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
- /** Wrap spans into lines, breaking at word boundaries (same logic as StyledTextHost) */
113
- private wrapSpans(spans: StyledSpan[], maxWidth: number): StyledSpan[][] {
114
- const lines: StyledSpan[][] = [[]]
115
- let lineWidth = 0
116
-
117
- for (const span of spans) {
118
- // Split span text into words (keeping whitespace as separate tokens)
119
- const tokens = span.text.split(/(\s+)/)
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
- // Remove empty lines at the end
165
- while (lines.length > 0 && lines[lines.length - 1].length === 0) {
166
- lines.pop()
167
- }
140
+ this.hasSpans = this.checkForSpans()
141
+ this.cachedSpans = this.hasSpans ? this.collectSpans() : null
142
+ this.prepared = true
143
+ }
168
144
 
169
- return lines.length > 0 ? lines : [[]]
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
- measure(maxW: number, maxH: number): Size {
180
- // Invalidate content cache at start of measure (will be recomputed on demand)
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
- this.hasSpans = this.checkForSpans()
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 = this.wrapSpans(spans, maxW)
189
- this.cachedWidth = maxW
190
- const h = Math.min(this.cachedStyledLines.length, maxH)
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, maxW), h: 1 }
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, maxW), ""] : this.wrapText(line, maxW),
208
+ idx < rawLines.length - 1 ? [...this.wrapText(line, constrained.w), ""] : this.wrapText(line, constrained.w),
210
209
  )
211
- this.cachedWidth = maxW
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, maxH) }
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), maxW))
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, maxH)
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
- super.layout(rect)
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(rect)
281
+ child.layout(layoutRect)
283
282
  }
284
283
  }
285
284
 
286
285
  render(buffer: CellBuffer, palette: Palette): void {
287
- if (!this.rect) return
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
- : this.wrap
300
- ? this.wrapSpans(spans, this.rect.w)
301
- : [spans]
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
- }
@@ -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 { alignInRect, type HAlign, type VAlign } from "../utils/index.js"
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
- super.layout(rect)
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(rect.w, rect.h)
51
- const { x, y } = alignInRect(rect, size, this.alignmentH, this.alignmentV)
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
 
@@ -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.dispatchPaste(this.pasteBuffer)
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
- process.exit(0)
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"
@@ -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 restore terminal. Defaults to true. */
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))
@@ -64,6 +64,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
64
64
  stdin: stdin as any,
65
65
  manualMode: true,
66
66
  skipTerminalSetup: true,
67
+ handleSignals: false,
67
68
  mode: options?.mode,
68
69
  diff: options?.diff,
69
70
  })
@@ -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
+ }
@@ -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"