@effect-tui/react 0.16.0 → 2.0.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.
Files changed (126) hide show
  1. package/README.md +9 -0
  2. package/dist/src/codeblock.d.ts +1 -1
  3. package/dist/src/codeblock.d.ts.map +1 -1
  4. package/dist/src/codeblock.js +2 -2
  5. package/dist/src/codeblock.js.map +1 -1
  6. package/dist/src/components/Markdown.js +3 -3
  7. package/dist/src/components/Markdown.js.map +1 -1
  8. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  9. package/dist/src/components/MultilineTextInput.js +133 -305
  10. package/dist/src/components/MultilineTextInput.js.map +1 -1
  11. package/dist/src/components/TextInput.d.ts.map +1 -1
  12. package/dist/src/components/TextInput.js +51 -98
  13. package/dist/src/components/TextInput.js.map +1 -1
  14. package/dist/src/components/text-editing.d.ts +61 -0
  15. package/dist/src/components/text-editing.d.ts.map +1 -1
  16. package/dist/src/components/text-editing.js +131 -0
  17. package/dist/src/components/text-editing.js.map +1 -1
  18. package/dist/src/hosts/base.d.ts +13 -2
  19. package/dist/src/hosts/base.d.ts.map +1 -1
  20. package/dist/src/hosts/base.js +74 -2
  21. package/dist/src/hosts/base.js.map +1 -1
  22. package/dist/src/hosts/box.d.ts +2 -2
  23. package/dist/src/hosts/box.d.ts.map +1 -1
  24. package/dist/src/hosts/box.js +29 -2
  25. package/dist/src/hosts/box.js.map +1 -1
  26. package/dist/src/hosts/canvas.d.ts +22 -2
  27. package/dist/src/hosts/canvas.d.ts.map +1 -1
  28. package/dist/src/hosts/canvas.js +99 -31
  29. package/dist/src/hosts/canvas.js.map +1 -1
  30. package/dist/src/hosts/codeblock.d.ts +8 -10
  31. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  32. package/dist/src/hosts/codeblock.js +36 -33
  33. package/dist/src/hosts/codeblock.js.map +1 -1
  34. package/dist/src/hosts/flex-container.d.ts +2 -2
  35. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  36. package/dist/src/hosts/flex-container.js +17 -2
  37. package/dist/src/hosts/flex-container.js.map +1 -1
  38. package/dist/src/hosts/index.d.ts +1 -1
  39. package/dist/src/hosts/index.d.ts.map +1 -1
  40. package/dist/src/hosts/index.js.map +1 -1
  41. package/dist/src/hosts/overlay-item.d.ts +2 -2
  42. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  43. package/dist/src/hosts/overlay-item.js +7 -2
  44. package/dist/src/hosts/overlay-item.js.map +1 -1
  45. package/dist/src/hosts/overlay.d.ts +2 -2
  46. package/dist/src/hosts/overlay.d.ts.map +1 -1
  47. package/dist/src/hosts/overlay.js +2 -2
  48. package/dist/src/hosts/overlay.js.map +1 -1
  49. package/dist/src/hosts/scroll.d.ts +7 -2
  50. package/dist/src/hosts/scroll.d.ts.map +1 -1
  51. package/dist/src/hosts/scroll.js +126 -45
  52. package/dist/src/hosts/scroll.js.map +1 -1
  53. package/dist/src/hosts/single-child.d.ts.map +1 -1
  54. package/dist/src/hosts/single-child.js +2 -0
  55. package/dist/src/hosts/single-child.js.map +1 -1
  56. package/dist/src/hosts/spacer.d.ts +1 -1
  57. package/dist/src/hosts/spacer.d.ts.map +1 -1
  58. package/dist/src/hosts/spacer.js +6 -1
  59. package/dist/src/hosts/spacer.js.map +1 -1
  60. package/dist/src/hosts/text.d.ts +20 -15
  61. package/dist/src/hosts/text.d.ts.map +1 -1
  62. package/dist/src/hosts/text.js +104 -71
  63. package/dist/src/hosts/text.js.map +1 -1
  64. package/dist/src/hosts/zstack.d.ts +2 -2
  65. package/dist/src/hosts/zstack.d.ts.map +1 -1
  66. package/dist/src/hosts/zstack.js +7 -2
  67. package/dist/src/hosts/zstack.js.map +1 -1
  68. package/dist/src/index.d.ts +1 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/internal/renderer/index.d.ts.map +1 -1
  71. package/dist/src/internal/renderer/index.js +41 -16
  72. package/dist/src/internal/renderer/index.js.map +1 -1
  73. package/dist/src/internal/renderer/types.d.ts +4 -0
  74. package/dist/src/internal/renderer/types.d.ts.map +1 -1
  75. package/dist/src/motion/hooks.d.ts +1 -1
  76. package/dist/src/motion/hooks.js +1 -1
  77. package/dist/src/reconciler/host-config.js +2 -2
  78. package/dist/src/reconciler/host-config.js.map +1 -1
  79. package/dist/src/reconciler/types.d.ts +5 -1
  80. package/dist/src/reconciler/types.d.ts.map +1 -1
  81. package/dist/src/utils/border.d.ts +1 -1
  82. package/dist/src/utils/border.d.ts.map +1 -1
  83. package/dist/src/utils/border.js +2 -0
  84. package/dist/src/utils/border.js.map +1 -1
  85. package/dist/src/utils/index.d.ts +2 -1
  86. package/dist/src/utils/index.d.ts.map +1 -1
  87. package/dist/src/utils/index.js +2 -1
  88. package/dist/src/utils/index.js.map +1 -1
  89. package/dist/src/utils/text-layout.d.ts +22 -0
  90. package/dist/src/utils/text-layout.d.ts.map +1 -0
  91. package/dist/src/utils/text-layout.js +37 -0
  92. package/dist/src/utils/text-layout.js.map +1 -0
  93. package/dist/src/utils/text-wrap.d.ts +26 -1
  94. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  95. package/dist/src/utils/text-wrap.js +106 -11
  96. package/dist/src/utils/text-wrap.js.map +1 -1
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/codeblock.tsx +2 -2
  100. package/src/components/Markdown.tsx +3 -3
  101. package/src/components/MultilineTextInput.tsx +138 -344
  102. package/src/components/TextInput.tsx +54 -99
  103. package/src/components/text-editing.ts +180 -0
  104. package/src/hosts/base.ts +86 -3
  105. package/src/hosts/box.ts +37 -2
  106. package/src/hosts/canvas.ts +120 -31
  107. package/src/hosts/codeblock.ts +46 -33
  108. package/src/hosts/flex-container.ts +21 -2
  109. package/src/hosts/index.ts +1 -1
  110. package/src/hosts/overlay-item.ts +8 -2
  111. package/src/hosts/overlay.ts +2 -2
  112. package/src/hosts/scroll.ts +142 -45
  113. package/src/hosts/single-child.ts +2 -0
  114. package/src/hosts/spacer.ts +6 -1
  115. package/src/hosts/text.ts +122 -75
  116. package/src/hosts/zstack.ts +7 -2
  117. package/src/index.ts +1 -1
  118. package/src/internal/renderer/index.ts +53 -20
  119. package/src/internal/renderer/types.ts +4 -0
  120. package/src/motion/hooks.ts +1 -1
  121. package/src/reconciler/host-config.ts +2 -2
  122. package/src/reconciler/types.ts +7 -1
  123. package/src/utils/border.ts +11 -1
  124. package/src/utils/index.ts +15 -1
  125. package/src/utils/text-layout.ts +65 -0
  126. package/src/utils/text-wrap.ts +135 -13
package/src/hosts/text.ts CHANGED
@@ -1,7 +1,14 @@
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
3
  import type { CommonProps, HostContext, HostInstance, Rect, Size } from "../reconciler/types.js"
4
- import { resolveInheritedBgStyle, styleIdFromProps, wrapSpans, wrapText } from "../utils/index.js"
4
+ import {
5
+ resolveInheritedBgStyle,
6
+ splitSpansByNewline,
7
+ spansDisplayWidth,
8
+ styleIdFromProps,
9
+ wrapSpansByLine,
10
+ wrapText,
11
+ } from "../utils/index.js"
5
12
  import { BaseHost, getInheritedBg } from "./base.js"
6
13
  import { LeafHost } from "./leaf.js"
7
14
 
@@ -50,6 +57,7 @@ export class TextHost extends BaseHost {
50
57
  // Cache wrapped lines between measure() and render()
51
58
  private cachedLines: string[] | null = null
52
59
  private cachedWidth = 0
60
+ private cachedStyledWrap = false
53
61
  // Cache content to avoid rescanning children each frame
54
62
  private cachedContent: string | null = null
55
63
  // Cache for styled mode
@@ -57,7 +65,6 @@ export class TextHost extends BaseHost {
57
65
  private cachedSpans: StyledSpan[] | null = null
58
66
  private hasSpans = false
59
67
  private explicitSpans: StyledSpan[] | null = null
60
- private prepared = false
61
68
 
62
69
  constructor(props: TextProps, ctx: HostContext) {
63
70
  super("text", props, ctx)
@@ -95,15 +102,6 @@ export class TextHost extends BaseHost {
95
102
  if (child.content) {
96
103
  spans.push({
97
104
  text: child.content,
98
- // Inherit TextHost's styles
99
- fg: this.fg,
100
- bg: this.bg,
101
- bold: this.bold,
102
- dimmed: this.dimmed,
103
- italic: this.italic,
104
- underline: this.underline,
105
- strikethrough: this.strikethrough,
106
- inverse: this.inverse,
107
105
  })
108
106
  }
109
107
  } else if (child instanceof SpanHost) {
@@ -111,15 +109,15 @@ export class TextHost extends BaseHost {
111
109
  if (content) {
112
110
  spans.push({
113
111
  text: content,
114
- // Span's styles, falling back to TextHost's
115
- fg: child.fg ?? this.fg,
116
- bg: child.bg ?? this.bg,
117
- bold: child.bold || this.bold,
118
- dimmed: child.dimmed || this.dimmed,
119
- italic: child.italic || this.italic,
120
- underline: child.underline || this.underline,
121
- strikethrough: child.strikethrough || this.strikethrough,
122
- inverse: child.inverse || this.inverse,
112
+ // Span's styles (TextHost applies fallbacks at render time)
113
+ fg: child.fg,
114
+ bg: child.bg,
115
+ bold: child.bold,
116
+ dimmed: child.dimmed,
117
+ italic: child.italic,
118
+ underline: child.underline,
119
+ strikethrough: child.strikethrough,
120
+ inverse: child.inverse,
123
121
  })
124
122
  }
125
123
  }
@@ -129,54 +127,51 @@ export class TextHost extends BaseHost {
129
127
  }
130
128
 
131
129
  private prepareContent(): void {
132
- this.invalidateContent()
133
130
  const useExplicitSpans = this.explicitSpans !== null
134
131
  if (useExplicitSpans) {
135
132
  this.hasSpans = false
136
133
  this.cachedSpans = null
137
- this.prepared = true
138
134
  return
139
135
  }
140
136
 
141
137
  this.hasSpans = this.checkForSpans()
142
138
  this.cachedSpans = this.hasSpans ? this.collectSpans() : null
143
- this.prepared = true
144
139
  }
145
140
 
146
- private ensurePrepared(): void {
147
- if (this.prepared) return
148
- this.prepareContent()
149
- }
150
-
151
- /** Invalidate content cache when children change */
152
- private invalidateContent(): void {
141
+ /** Reset content-related caches (text/spans/wrap). */
142
+ private resetContentCaches(): void {
153
143
  this.cachedContent = null
154
144
  this.cachedLines = null
155
145
  this.cachedStyledLines = null
146
+ this.cachedStyledWrap = false
156
147
  this.cachedSpans = null
157
- this.prepared = false
158
148
  }
159
149
 
160
- protected override prepareSelf(): void {
150
+ protected override prepareSelf(_layoutDirty: boolean, _renderDirty: boolean): void {
161
151
  this.prepareContent()
162
152
  }
163
153
 
154
+ override invalidateLayout(): void {
155
+ this.resetContentCaches()
156
+ super.invalidateLayout()
157
+ }
158
+
164
159
  override appendChild(child: HostInstance): void {
160
+ this.resetContentCaches()
165
161
  super.appendChild(child)
166
- this.invalidateContent()
167
162
  }
168
163
 
169
164
  override removeChild(child: HostInstance): void {
165
+ this.resetContentCaches()
170
166
  super.removeChild(child)
171
- this.invalidateContent()
172
167
  }
173
168
 
174
169
  override insertBefore(child: HostInstance, before: HostInstance): void {
170
+ this.resetContentCaches()
175
171
  super.insertBefore(child, before)
176
- this.invalidateContent()
177
172
  }
178
173
 
179
- measure(maxW: number, maxH: number): Size {
174
+ protected measureSelf(maxW: number, maxH: number): Size {
180
175
  const constrained = this.constrainProposal(maxW, maxH)
181
176
  this.ensurePrepared()
182
177
  const useExplicitSpans = this.explicitSpans !== null
@@ -185,18 +180,21 @@ export class TextHost extends BaseHost {
185
180
  if (useExplicitSpans || this.hasSpans) {
186
181
  const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
187
182
  if (this.wrap) {
188
- this.cachedStyledLines = wrapSpans(spans, constrained.w)
183
+ this.cachedStyledLines = wrapSpansByLine(spans, constrained.w)
189
184
  this.cachedWidth = constrained.w
185
+ this.cachedStyledWrap = true
190
186
  const h = Math.min(this.cachedStyledLines.length, constrained.h)
191
- const w = this.cachedStyledLines.reduce(
192
- (max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
193
- 0,
194
- )
187
+ const w = this.cachedStyledLines.reduce((max, line) => Math.max(max, spansDisplayWidth(line)), 0)
195
188
  return this.constrainResult({ w, h })
196
189
  }
197
- // Non-wrap styled mode
198
- const totalWidth = spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
199
- return this.constrainResult({ w: Math.min(totalWidth, constrained.w), h: 1 })
190
+ // Non-wrap styled mode (preserve explicit newlines)
191
+ const lines = splitSpansByNewline(spans)
192
+ this.cachedStyledLines = lines
193
+ this.cachedWidth = constrained.w
194
+ this.cachedStyledWrap = false
195
+ const maxLineWidth = lines.reduce((max, line) => Math.max(max, spansDisplayWidth(line)), 0)
196
+ const h = Math.min(lines.length, constrained.h)
197
+ return this.constrainResult({ w: Math.min(maxLineWidth, constrained.w), h })
200
198
  }
201
199
 
202
200
  // Simple mode: single style for all content
@@ -221,7 +219,7 @@ export class TextHost extends BaseHost {
221
219
  return this.constrainResult({ w, h })
222
220
  }
223
221
 
224
- override layout(rect: Rect): void {
222
+ protected override layoutSelf(rect: Rect): void {
225
223
  const layoutRect = this.layoutWithConstraints(rect)
226
224
  // Layout children (RawTextHost nodes) at same position
227
225
  for (const child of this.children) {
@@ -230,10 +228,7 @@ export class TextHost extends BaseHost {
230
228
  }
231
229
 
232
230
  render(buffer: CellBuffer, palette: Palette): void {
233
- if (!this.rect) {
234
- this.prepared = false
235
- return
236
- }
231
+ if (!this.rect) return
237
232
  this.ensurePrepared()
238
233
 
239
234
  // If text has no bg, inherit from parent box for proper highlight rendering
@@ -244,11 +239,11 @@ export class TextHost extends BaseHost {
244
239
  // Styled mode: render with per-span styles
245
240
  if (useExplicitSpans || this.hasSpans) {
246
241
  const lines =
247
- this.wrap && this.cachedStyledLines && this.cachedWidth === this.rect.w
242
+ this.cachedStyledLines && this.cachedWidth === this.rect.w && this.cachedStyledWrap === this.wrap
248
243
  ? this.cachedStyledLines
249
244
  : (() => {
250
245
  const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
251
- return this.wrap ? wrapSpans(spans, this.rect.w) : [spans]
246
+ return this.wrap ? wrapSpansByLine(spans, this.rect.w) : splitSpansByNewline(spans)
252
247
  })()
253
248
 
254
249
  for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
@@ -257,12 +252,12 @@ export class TextHost extends BaseHost {
257
252
  const spanStyleId = styleIdFromProps(palette, {
258
253
  fg: span.fg ?? this.fg,
259
254
  bg: span.bg ?? inheritedBg,
260
- bold: span.bold,
261
- dimmed: span.dimmed,
262
- italic: span.italic,
263
- underline: span.underline,
264
- strikethrough: span.strikethrough,
265
- inverse: span.inverse,
255
+ bold: span.bold ?? this.bold,
256
+ dimmed: span.dimmed ?? this.dimmed,
257
+ italic: span.italic ?? this.italic,
258
+ underline: span.underline ?? this.underline,
259
+ strikethrough: span.strikethrough ?? this.strikethrough,
260
+ inverse: span.inverse ?? this.inverse,
266
261
  })
267
262
  const availableWidth = this.rect.w - (x - this.rect.x)
268
263
  if (availableWidth <= 0) break
@@ -271,7 +266,6 @@ export class TextHost extends BaseHost {
271
266
  x += displayWidth(span.text)
272
267
  }
273
268
  }
274
- this.prepared = false
275
269
  return
276
270
  }
277
271
 
@@ -308,7 +302,6 @@ export class TextHost extends BaseHost {
308
302
  const lineWidth = Math.min(displayWidth(lines[i]), this.rect.w)
309
303
  buffer.drawText(this.rect.x, this.rect.y + i, lines[i], styleId, lineWidth)
310
304
  }
311
- this.prepared = false
312
305
  return
313
306
  }
314
307
 
@@ -322,11 +315,21 @@ export class TextHost extends BaseHost {
322
315
  const lineWidth = Math.min(displayWidth(rawLines[i]), this.rect.w)
323
316
  buffer.drawText(this.rect.x, this.rect.y + i, rawLines[i], styleId, lineWidth)
324
317
  }
325
- this.prepared = false
326
318
  }
327
319
 
328
320
  override updateProps(props: Record<string, unknown>): void {
329
321
  super.updateProps(props)
322
+ const prevFg = this.fg
323
+ const prevBg = this.bg
324
+ const prevBold = this.bold
325
+ const prevDimmed = this.dimmed
326
+ const prevItalic = this.italic
327
+ const prevUnderline = this.underline
328
+ const prevStrikethrough = this.strikethrough
329
+ const prevInverse = this.inverse
330
+ const prevWrap = this.wrap
331
+ const prevExplicitSpans = this.explicitSpans
332
+
330
333
  // Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
331
334
  this.fg = this.resolveSpringProp("fg", props.fg, (v) => {
332
335
  this.fg = v as Color
@@ -342,6 +345,26 @@ export class TextHost extends BaseHost {
342
345
  this.inverse = Boolean(props.inverse)
343
346
  this.wrap = Boolean(props.wrap)
344
347
  this.explicitSpans = "spans" in props ? ((props.spans as StyledSpan[] | undefined) ?? []) : null
348
+
349
+ const layoutChanged = prevWrap !== this.wrap || prevExplicitSpans !== this.explicitSpans
350
+ if (layoutChanged) {
351
+ this.invalidateLayout()
352
+ return
353
+ }
354
+
355
+ const renderChanged =
356
+ prevFg !== this.fg ||
357
+ prevBg !== this.bg ||
358
+ prevBold !== this.bold ||
359
+ prevDimmed !== this.dimmed ||
360
+ prevItalic !== this.italic ||
361
+ prevUnderline !== this.underline ||
362
+ prevStrikethrough !== this.strikethrough ||
363
+ prevInverse !== this.inverse
364
+
365
+ if (renderChanged) {
366
+ this.invalidateRender()
367
+ }
345
368
  }
346
369
  }
347
370
 
@@ -354,7 +377,7 @@ export class RawTextHost extends LeafHost {
354
377
  this.content = text
355
378
  }
356
379
 
357
- measure(maxW: number, _maxH: number): Size {
380
+ protected measureSelf(maxW: number, _maxH: number): Size {
358
381
  const w = Math.min(displayWidth(this.content), maxW)
359
382
  return { w, h: 1 }
360
383
  }
@@ -371,6 +394,7 @@ export class RawTextHost extends LeafHost {
371
394
 
372
395
  updateText(text: string): void {
373
396
  this.content = text
397
+ this.parent?.invalidateLayout?.()
374
398
  }
375
399
 
376
400
  override updateProps(_props: Record<string, unknown>): void {
@@ -416,12 +440,12 @@ export interface SpanProps extends CommonProps {
416
440
  export class SpanHost extends BaseHost {
417
441
  fg?: Color
418
442
  bg?: Color
419
- bold = false
420
- dimmed = false
421
- italic = false
422
- underline = false
423
- strikethrough = false
424
- inverse = false
443
+ bold?: boolean
444
+ dimmed?: boolean
445
+ italic?: boolean
446
+ underline?: boolean
447
+ strikethrough?: boolean
448
+ inverse?: boolean
425
449
 
426
450
  constructor(props: SpanProps, ctx: HostContext) {
427
451
  super("span", props, ctx)
@@ -436,7 +460,22 @@ export class SpanHost extends BaseHost {
436
460
  .join("")
437
461
  }
438
462
 
439
- measure(_maxW: number, _maxH: number): Size {
463
+ override appendChild(child: HostInstance): void {
464
+ super.appendChild(child)
465
+ this.parent?.invalidateLayout?.()
466
+ }
467
+
468
+ override removeChild(child: HostInstance): void {
469
+ super.removeChild(child)
470
+ this.parent?.invalidateLayout?.()
471
+ }
472
+
473
+ override insertBefore(child: HostInstance, before: HostInstance): void {
474
+ super.insertBefore(child, before)
475
+ this.parent?.invalidateLayout?.()
476
+ }
477
+
478
+ protected measureSelf(_maxW: number, _maxH: number): Size {
440
479
  // Span doesn't measure independently - parent TextHost handles layout
441
480
  return { w: 0, h: 0 }
442
481
  }
@@ -452,12 +491,20 @@ export class SpanHost extends BaseHost {
452
491
  // Individual props override textStyle object
453
492
  this.fg = props.fg !== undefined ? (props.fg as Color) : textStyle?.fg
454
493
  this.bg = props.bg !== undefined ? (props.bg as Color) : textStyle?.bg
455
- this.bold = props.bold !== undefined ? Boolean(props.bold) : Boolean(textStyle?.bold)
456
- this.dimmed = props.dimmed !== undefined ? Boolean(props.dimmed) : Boolean(textStyle?.dimmed)
457
- this.italic = props.italic !== undefined ? Boolean(props.italic) : Boolean(textStyle?.italic)
458
- this.underline = props.underline !== undefined ? Boolean(props.underline) : Boolean(textStyle?.underline)
459
- this.strikethrough =
460
- props.strikethrough !== undefined ? Boolean(props.strikethrough) : Boolean(textStyle?.strikethrough)
461
- this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
494
+ this.bold = props.bold !== undefined ? Boolean(props.bold) : textStyle?.bold
495
+ this.dimmed = props.dimmed !== undefined ? Boolean(props.dimmed) : textStyle?.dimmed
496
+ this.italic = props.italic !== undefined ? Boolean(props.italic) : textStyle?.italic
497
+ this.underline = props.underline !== undefined ? Boolean(props.underline) : textStyle?.underline
498
+ this.strikethrough = props.strikethrough !== undefined ? Boolean(props.strikethrough) : textStyle?.strikethrough
499
+ this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : textStyle?.inverse
500
+ this.invalidateLayout()
501
+ }
502
+
503
+ override invalidateLayout(): void {
504
+ this.parent?.invalidateLayout?.()
505
+ }
506
+
507
+ override invalidateRender(): void {
508
+ this.parent?.invalidateRender?.()
462
509
  }
463
510
  }
@@ -19,7 +19,7 @@ export class ZStackHost extends BaseHost {
19
19
  this.updateProps(props as unknown as Record<string, unknown>)
20
20
  }
21
21
 
22
- measure(maxW: number, maxH: number): Size {
22
+ protected measureSelf(maxW: number, maxH: number): Size {
23
23
  // Apply frame constraints to what we propose to children
24
24
  const constrained = this.constrainProposal(maxW, maxH)
25
25
 
@@ -43,7 +43,7 @@ export class ZStackHost extends BaseHost {
43
43
  return this.constrainResult(naturalSize)
44
44
  }
45
45
 
46
- override layout(rect: Rect): void {
46
+ protected override layoutSelf(rect: Rect): void {
47
47
  const layoutRect = this.layoutWithConstraints(rect)
48
48
 
49
49
  layoutAlignedChildren(layoutRect, this.children, this.cachedSizes, () => ({
@@ -60,10 +60,15 @@ export class ZStackHost extends BaseHost {
60
60
 
61
61
  override updateProps(props: Record<string, unknown>): void {
62
62
  super.updateProps(props)
63
+ const prevH = this.alignmentH
64
+ const prevV = this.alignmentV
63
65
  if (props.alignment !== undefined) {
64
66
  const a = props.alignment as ZStackProps["alignment"]
65
67
  if (a?.h) this.alignmentH = a.h
66
68
  if (a?.v) this.alignmentV = a.v
67
69
  }
70
+ if (prevH !== this.alignmentH || prevV !== this.alignmentV) {
71
+ this.invalidateLayout()
72
+ }
68
73
  }
69
74
  }
package/src/index.ts CHANGED
@@ -65,7 +65,7 @@ export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useShortcut, useTi
65
65
  export { isKey } from "./shortcuts.js"
66
66
  export { useFrameStats } from "./hooks/useFrameStats.js"
67
67
  export type { BorderKind, BoxProps } from "./hosts/box.js"
68
- export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
68
+ export type { CanvasCell, CanvasProps, DrawContext } from "./hosts/canvas.js"
69
69
  export type { HStackProps } from "./hosts/hstack.js"
70
70
  export type { ScrollAlign, ScrollAlignX, ScrollAlignY, ScrollAxis, ScrollLayoutChange, ScrollProps } from "./hosts/scroll.js"
71
71
  export type { SpacerProps } from "./hosts/spacer.js"
@@ -1,6 +1,12 @@
1
1
  import { performance } from "node:perf_hooks"
2
2
  import { fileURLToPath } from "node:url"
3
- import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
3
+ import {
4
+ ANSI,
5
+ bufferToString,
6
+ setEmojiWidth,
7
+ type KeyMsg,
8
+ type MouseMsg,
9
+ } from "@effect-tui/core"
4
10
  import React, { type ReactNode } from "react"
5
11
  import { createTerminalWriter, writeToTerminal } from "../../console/ConsoleCapture.js"
6
12
  import { DEFAULT_FPS } from "../../constants.js"
@@ -42,6 +48,7 @@ type HandledSignal = "SIGINT" | "SIGTERM"
42
48
 
43
49
  export function createRenderer(options?: RendererOptions): TuiRenderer {
44
50
  const fps = options?.fps ?? DEFAULT_FPS
51
+ const exitOnRenderError = options?.exitOnRenderError ?? true
45
52
  // Use custom stdout if provided, otherwise use process.stdout with bypassed capture
46
53
  let stdout: TuiWriteStream
47
54
  if (options?.stdout) {
@@ -82,6 +89,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
82
89
  const enableMouse = options?.enableMouse ?? mode === "fullscreen"
83
90
  const enableKittyKeyboard = options?.enableKittyKeyboard
84
91
  const debugHook = options?.debug?.onFrame
92
+ const emojiWidthOption = options?.emojiWidth
93
+ const envEmojiWidth =
94
+ process.env.TUI_EMOJI_WIDTH ?? process.env.EFFECT_TUI_EMOJI_WIDTH ?? process.env.EMOJI_WIDTH
85
95
 
86
96
  const keyboardProbe =
87
97
  !skipTerminalSetup && enableKittyKeyboard !== false
@@ -98,6 +108,12 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
98
108
  })
99
109
  : null
100
110
 
111
+ if (emojiWidthOption === 1 || emojiWidthOption === 2) {
112
+ setEmojiWidth(emojiWidthOption)
113
+ } else if (envEmojiWidth === "1" || envEmojiWidth === "2") {
114
+ setEmojiWidth(envEmojiWidth === "1" ? 1 : 2)
115
+ }
116
+
101
117
  // Initialize state
102
118
  const state = new RendererState(stdout.columns || 80, stdout.rows || 24)
103
119
  const events = new EventBus()
@@ -117,6 +133,29 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
117
133
  // Static content renderer (inline mode only)
118
134
  const staticRenderer = mode === "inline" ? new StaticContentRenderer(stdout, state.palette) : null
119
135
 
136
+ let renderer!: TuiRenderer
137
+ let cleanedUp = false
138
+ const cleanup = () => {
139
+ if (cleanedUp) return
140
+ cleanedUp = true
141
+ renderer.stop()
142
+ }
143
+
144
+ const handleRenderError = (err: unknown) => {
145
+ const error = err instanceof Error ? err : new Error(String(err))
146
+ cleanup()
147
+ writeToTerminal(`\n[effect-tui] Render error:\n${error.stack || error.message}\n`)
148
+ if (exitOnRenderError) process.exit(1)
149
+ }
150
+
151
+ const filterInput = keyboardProbe
152
+ ? (input: string) => {
153
+ let output = input
154
+ if (keyboardProbe) output = keyboardProbe.handleInput(output)
155
+ return output
156
+ }
157
+ : undefined
158
+
120
159
  // Input processing
121
160
  const inputProcessor = new InputProcessor({
122
161
  exitOnCtrlC,
@@ -135,7 +174,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
135
174
  renderer.stop()
136
175
  requestExit(0)
137
176
  },
138
- filterInput: keyboardProbe ? (input) => keyboardProbe.handleInput(input) : undefined,
177
+ filterInput,
139
178
  })
140
179
 
141
180
  const handleInlineFullRerender = (): string => {
@@ -201,9 +240,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
201
240
  const container = (renderer as TuiRendererInternal)._container
202
241
  const root = container?.root ?? null
203
242
 
204
- // Must render if dirty OR if static content needs flushing
205
- if ((!state.dirty && !container?.staticDirty) || !root) return
206
- state.dirty = false
243
+ // Must render if dirty OR if static content needs flushing
244
+ if ((!state.dirty && !container?.staticDirty) || !root) return
245
+ state.dirty = false
207
246
 
208
247
  try {
209
248
  // Handle full rerender on resize (Ink-style: clear everything + replay static)
@@ -294,8 +333,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
294
333
  if (debugHook) debugHook(stats)
295
334
  if (events.hasFrameHandlers) events.dispatchFrame(stats)
296
335
  } catch (err) {
297
- console.error("[effect-tui] Render error:", err)
298
- state.markDirty()
336
+ handleRenderError(err)
299
337
  }
300
338
  }
301
339
 
@@ -318,7 +356,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
318
356
  let unregisterProcessHandlers: (() => void) | null = null
319
357
 
320
358
  // Build renderer object
321
- const renderer: TuiRenderer = {
359
+ renderer = {
322
360
  get width() {
323
361
  return state.width
324
362
  },
@@ -383,15 +421,17 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
383
421
 
384
422
  // Terminal setup
385
423
  terminal.setup()
386
- keyboardProbe?.start()
387
- if (!keyboardProbe && !skipTerminalSetup) {
388
- stdout.write(ANSI.modifyOtherKeys.enable)
389
- }
390
424
 
391
425
  // Input handling
392
426
  state.inputHandler = (data: Buffer) => inputProcessor.process(data)
393
427
  stdin.on("data", state.inputHandler)
394
428
 
429
+ keyboardProbe?.start()
430
+ // No emoji width probing.
431
+ if (!keyboardProbe && !skipTerminalSetup) {
432
+ stdout.write(ANSI.modifyOtherKeys.enable)
433
+ }
434
+
395
435
  // Resize handling
396
436
  state.resizeHandler = () => {
397
437
  const newWidth = stdout.columns || 80
@@ -415,13 +455,6 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
415
455
 
416
456
  // Process exit handlers - ensure terminal is restored on any exit
417
457
  // These handlers are critical for proper cleanup when process.exit() is called
418
- let cleanedUp = false
419
- const cleanup = () => {
420
- if (cleanedUp) return
421
- cleanedUp = true
422
- renderer.stop()
423
- }
424
-
425
458
  if (handleSignals) {
426
459
  // Handle normal process exit (synchronous - runs before exit completes)
427
460
  onExit = () => cleanup()
@@ -470,7 +503,7 @@ export interface Root {
470
503
  export function createRoot(renderer: TuiRenderer): Root {
471
504
  const hostContext: HostContext = {
472
505
  requestRender: () => renderer.requestRender(),
473
- requestImmediateRender: () => renderer.renderNow(),
506
+ requestRenderNow: () => renderer.renderNow(),
474
507
  }
475
508
 
476
509
  const container: Container = {
@@ -87,6 +87,8 @@ export interface RendererOptions {
87
87
  handleSignals?: boolean
88
88
  /** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
89
89
  exitOnSignal?: boolean
90
+ /** Exit the process on render errors after logging to terminal. Defaults to true. */
91
+ exitOnRenderError?: boolean
90
92
  /** Override exit codes for signals. Defaults: SIGINT=130, SIGTERM=143. */
91
93
  signalExitCodes?: Partial<Record<"SIGINT" | "SIGTERM", number>>
92
94
  /** Enable diffed rendering (per-line). Defaults to true in runtime, false in manualMode (tests). */
@@ -101,6 +103,8 @@ export interface RendererOptions {
101
103
  enableMouse?: boolean
102
104
  /** Enable Kitty/xterm keyboard protocols for enhanced modifier detection (default true). */
103
105
  enableKittyKeyboard?: boolean
106
+ /** Emoji width handling: 1 or 2 forces width. */
107
+ emojiWidth?: 1 | 2
104
108
  /** Optional per-frame diagnostics hook. Called after each frame is written. */
105
109
  debug?: {
106
110
  onFrame?: (stats: FrameStats) => void
@@ -221,7 +221,7 @@ export function useColorMotionValue(initial: ColorInput): ColorMotionValue {
221
221
  * // In canvas draw callback
222
222
  * <canvas draw={(ctx) => {
223
223
  * const { r, g, b } = colorMv.get()
224
- * ctx.fill(0, 0, 10, 5, "█", { fg: Colors.rgb(r, g, b) })
224
+ * ctx.fillRect(0, 0, 10, 5, "█", { fg: Colors.rgb(r, g, b) })
225
225
  * }} />
226
226
  */
227
227
  export function useColorSpring(
@@ -110,8 +110,8 @@ const hostConfig = {
110
110
 
111
111
  resetAfterCommit(container: Container) {
112
112
  // If static content is dirty, flush immediately (bypassing throttle)
113
- if (container.staticDirty && container.ctx.requestImmediateRender) {
114
- container.ctx.requestImmediateRender()
113
+ if (container.staticDirty && container.ctx.requestRenderNow) {
114
+ container.ctx.requestRenderNow()
115
115
  } else {
116
116
  container.ctx.requestRender()
117
117
  }
@@ -32,6 +32,12 @@ export interface HostInstance {
32
32
  /** Optional pre-frame hook for cache prep (called before measure/layout/render). */
33
33
  prepareFrame?(): void
34
34
 
35
+ /** @internal Mark layout-related caches dirty. */
36
+ invalidateLayout?(): void
37
+
38
+ /** @internal Mark render-related caches dirty. */
39
+ invalidateRender?(): void
40
+
35
41
  /** Update props from React */
36
42
  updateProps(props: Record<string, unknown>): void
37
43
 
@@ -51,7 +57,7 @@ export interface HostInstance {
51
57
  export interface HostContext {
52
58
  requestRender(): void
53
59
  /** @internal Trigger immediate render (bypasses throttling) for static content */
54
- requestImmediateRender?(): void
60
+ requestRenderNow?(): void
55
61
  }
56
62
 
57
63
  /**
@@ -4,7 +4,15 @@
4
4
 
5
5
  import type { CellBuffer } from "@effect-tui/core"
6
6
 
7
- export type BorderKind = "none" | "rounded" | "square" | "double" | "heavy" | "dashed" | "ascii"
7
+ export type BorderKind =
8
+ | "none"
9
+ | "rounded"
10
+ | "round"
11
+ | "square"
12
+ | "double"
13
+ | "heavy"
14
+ | "dashed"
15
+ | "ascii"
8
16
 
9
17
  export interface BorderChars {
10
18
  tl: string // top-left
@@ -33,6 +41,7 @@ export interface TableBorderChars extends BorderChars {
33
41
  export function borderChars(kind: BorderKind): BorderChars {
34
42
  switch (kind) {
35
43
  case "rounded":
44
+ case "round":
36
45
  return { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" }
37
46
  case "square":
38
47
  return { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" }
@@ -55,6 +64,7 @@ export function borderChars(kind: BorderKind): BorderChars {
55
64
  export function tableBorderChars(kind: BorderKind): TableBorderChars {
56
65
  switch (kind) {
57
66
  case "rounded":
67
+ case "round":
58
68
  return { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", tt: "┬", bt: "┴", lt: "├", rt: "┤", cross: "┼" }
59
69
  case "square":
60
70
  return { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", tt: "┬", bt: "┴", lt: "├", rt: "┤", cross: "┼" }