@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.
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/jsx-runtime.ts CHANGED
@@ -11,7 +11,7 @@ import type { OverlayProps } from "./src/hosts/overlay.js"
11
11
  import type { OverlayItemProps } from "./src/hosts/overlay-item.js"
12
12
  import type { ScrollProps } from "./src/hosts/scroll.js"
13
13
  import type { SpacerProps } from "./src/hosts/spacer.js"
14
- import type { SpanProps, StyledTextProps, TextProps } from "./src/hosts/text.js"
14
+ import type { SpanProps, TextProps } from "./src/hosts/text.js"
15
15
  import type { VStackProps } from "./src/hosts/vstack.js"
16
16
  import type { ZStackProps } from "./src/hosts/zstack.js"
17
17
 
@@ -40,7 +40,6 @@ export declare namespace JSX {
40
40
  export interface IntrinsicElements {
41
41
  text: TextProps & { children?: React.ReactNode }
42
42
  span: SpanProps & { children?: React.ReactNode }
43
- styledtext: StyledTextProps
44
43
  spacer: SpacerProps
45
44
  vstack: VStackProps & { children?: React.ReactNode; __static?: boolean }
46
45
  hstack: HStackProps & { children?: React.ReactNode }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "React bindings for @effect-tui/core",
5
5
  "type": "module",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "bun run typecheck && bun run build"
84
84
  },
85
85
  "dependencies": {
86
- "@effect-tui/core": "^0.13.0",
86
+ "@effect-tui/core": "^0.14.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -229,7 +229,7 @@ function parseMarkdown(content: string): MdElement[] {
229
229
  }
230
230
 
231
231
  /**
232
- * Convert markdown spans to styled spans for StyledTextHost
232
+ * Convert markdown spans to styled spans for TextHost spans prop.
233
233
  */
234
234
  function toStyledSpans(spans: MdSpan[], theme: Required<MarkdownTheme>): StyledSpan[] {
235
235
  const result: StyledSpan[] = []
@@ -258,7 +258,7 @@ function toStyledSpans(spans: MdSpan[], theme: Required<MarkdownTheme>): StyledS
258
258
 
259
259
  /**
260
260
  * Render inline spans as text elements (used for non-wrapping contexts like lists).
261
- * For wrapping paragraphs, use <styledtext> instead.
261
+ * For wrapping paragraphs, use <text spans={...} wrap /> instead.
262
262
  */
263
263
  function renderSpans(spans: MdSpan[], theme: Required<MarkdownTheme>) {
264
264
  return spans.map((span, i) => {
@@ -356,9 +356,9 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
356
356
  </text>
357
357
  )
358
358
  case "paragraph":
359
- // Use styledtext for proper wrapping of styled inline text
359
+ // Use spans for proper wrapping of styled inline text
360
360
  if (wrap) {
361
- return <styledtext key={i} spans={toStyledSpans(el.spans, theme)} wrap />
361
+ return <text key={i} spans={toStyledSpans(el.spans, theme)} wrap />
362
362
  }
363
363
  return <hstack key={i}>{renderSpans(el.spans, theme)}</hstack>
364
364
  case "code":
@@ -377,7 +377,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
377
377
  <hstack key={i}>
378
378
  <text fg={theme.quoteBorder}>{"│ "}</text>
379
379
  {wrap ? (
380
- <styledtext spans={toStyledSpans(el.spans, theme)} wrap />
380
+ <text spans={toStyledSpans(el.spans, theme)} wrap />
381
381
  ) : (
382
382
  renderSpans(el.spans, theme)
383
383
  )}
@@ -390,7 +390,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
390
390
  <hstack key={j}>
391
391
  <text fg={theme.listMarker}>{" • "}</text>
392
392
  {wrap ? (
393
- <styledtext spans={toStyledSpans(item, theme)} wrap />
393
+ <text spans={toStyledSpans(item, theme)} wrap />
394
394
  ) : (
395
395
  renderSpans(item, theme)
396
396
  )}
@@ -405,7 +405,7 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
405
405
  <hstack key={j}>
406
406
  <text fg={theme.listMarker}>{` ${el.start + j}. `}</text>
407
407
  {wrap ? (
408
- <styledtext spans={toStyledSpans(item, theme)} wrap />
408
+ <text spans={toStyledSpans(item, theme)} wrap />
409
409
  ) : (
410
410
  renderSpans(item, theme)
411
411
  )}
@@ -1,6 +1,7 @@
1
1
  import { type Color, Colors, displayWidth, graphemes } from "@effect-tui/core"
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { useKeyboard } from "../hooks/use-keyboard.js"
4
+ import { usePaste } from "../hooks/use-paste.js"
4
5
  import type { DrawContext } from "../hosts/canvas.js"
5
6
  import {
6
7
  deleteCharBackwardMultiline,
@@ -654,6 +655,19 @@ export function MultilineTextInput({
654
655
 
655
656
  useKeyboard(handleKey, { phase: "any" })
656
657
 
658
+ usePaste(
659
+ (paste) => {
660
+ if (!focused) return
661
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
662
+ const result = insertTextMultiline(state, paste.text)
663
+ if (result.changed) {
664
+ onChange(result.state.lines.join("\n"))
665
+ setCursor(result.state.cursor)
666
+ }
667
+ },
668
+ { stopPropagation: true },
669
+ )
670
+
657
671
  const draw = useCallback(
658
672
  (ctx: DrawContext) => {
659
673
  // Update content width for layout calculations
@@ -1,6 +1,7 @@
1
1
  import { type Color, Colors, displayWidth } from "@effect-tui/core"
2
2
  import { useCallback, useEffect, useState } from "react"
3
3
  import { useKeyboard } from "../hooks/use-keyboard.js"
4
+ import { usePaste } from "../hooks/use-paste.js"
4
5
  import type { DrawContext } from "../hosts/canvas.js"
5
6
  import {
6
7
  deleteCharBackward,
@@ -241,6 +242,23 @@ export function TextInput({
241
242
 
242
243
  useKeyboard(handleKey, { phase: "any" })
243
244
 
245
+ usePaste(
246
+ (paste) => {
247
+ if (!focused) return
248
+ const normalized = paste.text.replace(/[\r\n]+/g, " ")
249
+ const state: TextState = { text: value, cursor: cursorPos, killRing }
250
+ const result = insertText(state, normalized)
251
+ if (result.changed) {
252
+ onChange(result.state.text)
253
+ setCursorPos(result.state.cursor)
254
+ if (result.state.killRing !== killRing) {
255
+ setKillRing(result.state.killRing)
256
+ }
257
+ }
258
+ },
259
+ { stopPropagation: true },
260
+ )
261
+
244
262
  const draw = useCallback(
245
263
  (ctx: DrawContext) => {
246
264
  const displayText = value || placeholder
package/src/hosts/base.ts CHANGED
@@ -113,6 +113,21 @@ export abstract class BaseHost implements HostInstance {
113
113
  // which would overwrite any values set here.
114
114
  }
115
115
 
116
+ /**
117
+ * Optional pre-frame hook. BaseHost will call prepareSelf() and then recurse into children.
118
+ */
119
+ prepareFrame(): void {
120
+ this.prepareSelf()
121
+ for (const child of this.children) {
122
+ child.prepareFrame?.()
123
+ }
124
+ }
125
+
126
+ /** Override in subclasses to precompute caches once per frame. */
127
+ protected prepareSelf(): void {
128
+ // Default no-op
129
+ }
130
+
116
131
  /**
117
132
  * Resolve a prop that may be a MotionValue/ColorMotionValue.
118
133
  * If it's a spring, subscribes to changes and returns current value.
@@ -193,6 +208,14 @@ export abstract class BaseHost implements HostInstance {
193
208
  abstract render(buffer: CellBuffer, palette: Palette): void
194
209
 
195
210
  layout(rect: Rect): void {
211
+ this.layoutWithConstraints(rect)
212
+ }
213
+
214
+ /**
215
+ * Apply frame constraints and update this.rect.
216
+ * Returns the constrained rect for convenience in subclasses.
217
+ */
218
+ protected layoutWithConstraints(rect: Rect): Rect {
196
219
  // Apply frame constraints to the assigned rect
197
220
  let { w, h } = rect
198
221
 
@@ -214,6 +237,8 @@ export abstract class BaseHost implements HostInstance {
214
237
  this._lastLayoutH = h
215
238
  this.onLayout({ width: w, height: h, x: rect.x, y: rect.y })
216
239
  }
240
+
241
+ return this.rect
217
242
  }
218
243
 
219
244
  /**
@@ -322,6 +347,16 @@ export abstract class BaseHost implements HostInstance {
322
347
  this.onLayout = typeof props.onLayout === "function" ? (props.onLayout as typeof this.onLayout) : undefined
323
348
  }
324
349
 
350
+ /**
351
+ * Apply a default greedy value when the prop is omitted.
352
+ * Subclasses with greedy defaults should call this after super.updateProps().
353
+ */
354
+ protected applyGreedyDefault(props: Record<string, unknown>, fallback: number): void {
355
+ if (!("greedy" in props)) {
356
+ this.greedy = fallback
357
+ }
358
+ }
359
+
325
360
  destroy(): void {
326
361
  this.clearSpringSubscriptions()
327
362
  // Override in subclasses if cleanup needed
package/src/hosts/box.ts CHANGED
@@ -6,9 +6,9 @@ import {
6
6
  type BorderKind,
7
7
  borderChars,
8
8
  drawBorder,
9
+ fillRectWithInheritedBg,
9
10
  type Padding,
10
11
  type PaddingInput,
11
- resolveInheritedBgStyle,
12
12
  resolvePadding,
13
13
  toColorValue,
14
14
  } from "../utils/index.js"
@@ -82,14 +82,14 @@ export class BoxHost extends SingleChildHost {
82
82
  }
83
83
 
84
84
  override layout(rect: Rect): void {
85
- super.layout(rect)
85
+ const layoutRect = this.layoutWithConstraints(rect)
86
86
 
87
87
  const t = this.borderThickness
88
88
  const innerRect: Rect = {
89
- x: rect.x + t + this.padding.left,
90
- y: rect.y + t + this.padding.top,
91
- w: Math.max(0, rect.w - this.insetX),
92
- h: Math.max(0, rect.h - this.insetY),
89
+ x: layoutRect.x + t + this.padding.left,
90
+ y: layoutRect.y + t + this.padding.top,
91
+ w: Math.max(0, layoutRect.w - this.insetX),
92
+ h: Math.max(0, layoutRect.h - this.insetY),
93
93
  }
94
94
 
95
95
  // Layout single child
@@ -105,8 +105,7 @@ export class BoxHost extends SingleChildHost {
105
105
 
106
106
  // Always clear our rect to ensure stale backgrounds are removed.
107
107
  // If no explicit bg, inherit from parent (so child boxes are transparent over parents)
108
- const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
109
- buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
108
+ fillRectWithInheritedBg(buffer, palette, { x, y, w, h }, this.bg, this.parent)
110
109
 
111
110
  // Draw border
112
111
  if (this.border !== "none" && w >= 2 && h >= 2) {
@@ -79,10 +79,12 @@ export class CanvasHost extends BaseHost {
79
79
  }
80
80
 
81
81
  measure(maxW: number, maxH: number): Size {
82
- return {
83
- w: this.fixedWidth ?? maxW,
84
- h: this.fixedHeight ?? maxH,
82
+ const constrained = this.constrainProposal(maxW, maxH)
83
+ const size = {
84
+ w: this.fixedWidth ?? constrained.w,
85
+ h: this.fixedHeight ?? constrained.h,
85
86
  }
87
+ return this.constrainResult(size)
86
88
  }
87
89
 
88
90
  render(buffer: CellBuffer, palette: Palette): void {
@@ -50,6 +50,7 @@ export class CodeBlockHost extends BaseHost {
50
50
  }
51
51
 
52
52
  measure(maxW: number, maxH: number): Size {
53
+ const constrained = this.constrainProposal(maxW, maxH)
53
54
  this.cachedLineWidths = this.lines.map((l) => lineDisplayWidth(l))
54
55
  this.gutterWidth = this.computeGutterWidth()
55
56
 
@@ -58,10 +59,10 @@ export class CodeBlockHost extends BaseHost {
58
59
 
59
60
  const innerHeight = Math.max(1, this.lines.length) + this.insetY
60
61
 
61
- return {
62
- w: Math.min(maxW, contentW),
63
- h: Math.min(maxH, innerHeight),
64
- }
62
+ return this.constrainResult({
63
+ w: Math.min(constrained.w, contentW),
64
+ h: Math.min(constrained.h, innerHeight),
65
+ })
65
66
  }
66
67
 
67
68
  override layout(rect: Rect): void {
@@ -103,17 +103,15 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
103
103
  }
104
104
 
105
105
  override layout(rect: Rect): void {
106
- super.layout(rect)
107
- // Use this.rect (constrained) not rect (raw input)
108
- if (!this.rect) return
106
+ const layoutRect = this.layoutWithConstraints(rect)
109
107
  const stretchCross = this.axis === "vertical" ? this.alignment === "leading" : this.alignment === "top"
110
108
  const insetX = this.padding.left + this.padding.right
111
109
  const insetY = this.padding.top + this.padding.bottom
112
110
  const innerRect: Rect = {
113
- x: this.rect.x + this.padding.left,
114
- y: this.rect.y + this.padding.top,
115
- w: Math.max(0, this.rect.w - insetX),
116
- h: Math.max(0, this.rect.h - insetY),
111
+ x: layoutRect.x + this.padding.left,
112
+ y: layoutRect.y + this.padding.top,
113
+ w: Math.max(0, layoutRect.w - insetX),
114
+ h: Math.max(0, layoutRect.h - insetY),
117
115
  }
118
116
  layoutFlex(
119
117
  this.axis,
@@ -8,7 +8,7 @@ import { OverlayHost } from "./overlay.js"
8
8
  import { OverlayItemHost } from "./overlay-item.js"
9
9
  import { ScrollHost } from "./scroll.js"
10
10
  import { SpacerHost } from "./spacer.js"
11
- import { RawTextHost, SpanHost, StyledTextHost, TextHost } from "./text.js"
11
+ import { RawTextHost, SpanHost, TextHost } from "./text.js"
12
12
  import { VStackHost } from "./vstack.js"
13
13
  import { ZStackHost } from "./zstack.js"
14
14
 
@@ -25,12 +25,10 @@ export { SpacerHost, type SpacerProps } from "./spacer.js"
25
25
  export {
26
26
  RawTextHost,
27
27
  SpanHost,
28
- StyledTextHost,
29
28
  TextHost,
30
29
  type SpanProps,
31
30
  type SpanStyle,
32
31
  type StyledSpan,
33
- type StyledTextProps,
34
32
  type TextProps,
35
33
  } from "./text.js"
36
34
  export { VStackHost, type VStackProps } from "./vstack.js"
@@ -41,7 +39,6 @@ export { ZStackHost, type ZStackProps } from "./zstack.js"
41
39
  export const hostRegistry: Record<string, new (props: any, ctx: HostContext) => BaseHost> = {
42
40
  text: TextHost,
43
41
  span: SpanHost,
44
- styledtext: StyledTextHost,
45
42
  spacer: SpacerHost,
46
43
  vstack: VStackHost,
47
44
  hstack: HStackHost,
@@ -38,8 +38,8 @@ export class OverlayItemHost extends SingleChildHost {
38
38
  }
39
39
 
40
40
  override layout(rect: Rect): void {
41
- this.rect = rect
42
- this.child?.layout(rect)
41
+ const layoutRect = this.layoutWithConstraints(rect)
42
+ this.child?.layout(layoutRect)
43
43
  }
44
44
 
45
45
  override render(buffer: CellBuffer, palette: Palette): void {
@@ -18,7 +18,7 @@
18
18
 
19
19
  import type { CellBuffer, Palette, Rect, Size } from "@effect-tui/core"
20
20
  import type { CommonProps, HostContext } from "../reconciler/types.js"
21
- import { alignInRect } from "../utils/index.js"
21
+ import { alignedChildRect } from "../utils/index.js"
22
22
  import { BaseHost } from "./base.js"
23
23
  import type { OverlayItemHost } from "./overlay-item.js"
24
24
 
@@ -29,6 +29,7 @@ export class OverlayHost extends BaseHost {
29
29
 
30
30
  constructor(props: OverlayProps, ctx: HostContext) {
31
31
  super("overlay", props, ctx)
32
+ this.updateProps(props as unknown as Record<string, unknown>)
32
33
  }
33
34
 
34
35
  override measure(maxW: number, maxH: number): Size {
@@ -56,29 +57,22 @@ export class OverlayHost extends BaseHost {
56
57
  }
57
58
 
58
59
  override layout(rect: Rect): void {
59
- this.rect = rect
60
+ const layoutRect = this.layoutWithConstraints(rect)
60
61
 
61
62
  // Layout base child to fill our rect
62
63
  const baseChild = this.children[0]
63
64
  if (baseChild) {
64
- baseChild.layout(rect)
65
+ baseChild.layout(layoutRect)
65
66
  }
66
67
 
67
68
  // Layout overlay children with their alignment
68
69
  for (let i = 1; i < this.children.length; i++) {
69
70
  const child = this.children[i]
70
- const size = this.cachedSizes[i] ?? child.measure(rect.w, rect.h)
71
+ const size = this.cachedSizes[i] ?? child.measure(layoutRect.w, layoutRect.h)
71
72
 
72
73
  // Read alignment from OverlayItemHost (use type check, not instanceof, for bundler compatibility)
73
74
  const alignment = child.type === "overlayItem" ? (child as OverlayItemHost).alignment : {}
74
- const { x, y } = alignInRect(rect, size, alignment.h ?? "center", alignment.v ?? "center")
75
-
76
- child.layout({
77
- x,
78
- y,
79
- w: Math.min(rect.w, size.w),
80
- h: Math.min(rect.h, size.h),
81
- })
75
+ child.layout(alignedChildRect(layoutRect, size, alignment.h ?? "center", alignment.v ?? "center"))
82
76
  }
83
77
  }
84
78
 
@@ -1,7 +1,7 @@
1
1
  // scroll.ts — Scrollable container host
2
2
  import type { CellBuffer, Color, Palette } from "@effect-tui/core"
3
3
  import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
4
- import { resolveInheritedBgStyle } from "../utils/index.js"
4
+ import { fillRectWithInheritedBg } from "../utils/index.js"
5
5
  import { SingleChildHost } from "./single-child.js"
6
6
 
7
7
  export interface ScrollProps extends CommonProps {
@@ -55,10 +55,14 @@ export class ScrollHost extends SingleChildHost {
55
55
  private contentWidth = 0
56
56
  private contentHeight = 0
57
57
  // Track last reported sizes to avoid redundant callbacks
58
- private lastReportedContentW = 0
59
- private lastReportedContentH = 0
58
+ private lastReportedContentW = -1
59
+ private lastReportedContentH = -1
60
+ private lastViewportW = -1
61
+ private lastViewportH = -1
60
62
  private lastRectX = -1
61
63
  private lastRectY = -1
64
+ private lastRectW = -1
65
+ private lastRectH = -1
62
66
  // Track if we were at end (for sticky behavior)
63
67
  private wasAtEnd = true
64
68
  private wasAtEndX = true
@@ -104,7 +108,7 @@ export class ScrollHost extends SingleChildHost {
104
108
  }
105
109
 
106
110
  override layout(rect: Rect): void {
107
- super.layout(rect)
111
+ const layoutRect = this.layoutWithConstraints(rect)
108
112
 
109
113
  // Report content size if changed (deferred from measure() to keep it pure)
110
114
  if (this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH) {
@@ -114,24 +118,32 @@ export class ScrollHost extends SingleChildHost {
114
118
  }
115
119
 
116
120
  // Report viewport size if changed (for useScroll hook)
117
- // Always report on every layout - React may have recreated the hook with stale refs
118
- if (this.onViewportSize) {
119
- this.onViewportSize(rect.w, rect.h)
121
+ if (this.onViewportSize && (layoutRect.w !== this.lastViewportW || layoutRect.h !== this.lastViewportH)) {
122
+ this.lastViewportW = layoutRect.w
123
+ this.lastViewportH = layoutRect.h
124
+ this.onViewportSize(layoutRect.w, layoutRect.h)
120
125
  }
121
126
 
122
127
  // Report rect if position changed (for hit testing)
123
- if (rect.x !== this.lastRectX || rect.y !== this.lastRectY) {
124
- this.lastRectX = rect.x
125
- this.lastRectY = rect.y
126
- this.onRect?.(rect.x, rect.y, rect.w, rect.h)
128
+ if (
129
+ layoutRect.x !== this.lastRectX ||
130
+ layoutRect.y !== this.lastRectY ||
131
+ layoutRect.w !== this.lastRectW ||
132
+ layoutRect.h !== this.lastRectH
133
+ ) {
134
+ this.lastRectX = layoutRect.x
135
+ this.lastRectY = layoutRect.y
136
+ this.lastRectW = layoutRect.w
137
+ this.lastRectH = layoutRect.h
138
+ this.onRect?.(layoutRect.x, layoutRect.y, layoutRect.w, layoutRect.h)
127
139
  }
128
140
 
129
141
  const child = this.child
130
142
  if (!child) return
131
143
 
132
144
  // Calculate max scroll offsets
133
- const maxScrollY = Math.max(0, this.contentHeight - rect.h)
134
- const maxScrollX = Math.max(0, this.contentWidth - rect.w)
145
+ const maxScrollY = Math.max(0, this.contentHeight - layoutRect.h)
146
+ const maxScrollX = Math.max(0, this.contentWidth - layoutRect.w)
135
147
 
136
148
  // Start with the offset from props (controlled by useScroll)
137
149
  let scrollY = this.offset
@@ -185,20 +197,20 @@ export class ScrollHost extends SingleChildHost {
185
197
 
186
198
  // Handle alignment when content is smaller than viewport
187
199
  if (this.align === "end") {
188
- if (this.contentHeight < rect.h && (this.axis === "vertical" || this.axis === "both")) {
200
+ if (this.contentHeight < layoutRect.h && (this.axis === "vertical" || this.axis === "both")) {
189
201
  // Align to bottom
190
- scrollY = -(rect.h - this.contentHeight)
202
+ scrollY = -(layoutRect.h - this.contentHeight)
191
203
  }
192
- if (this.contentWidth < rect.w && (this.axis === "horizontal" || this.axis === "both")) {
204
+ if (this.contentWidth < layoutRect.w && (this.axis === "horizontal" || this.axis === "both")) {
193
205
  // Align to right
194
- scrollX = -(rect.w - this.contentWidth)
206
+ scrollX = -(layoutRect.w - this.contentWidth)
195
207
  }
196
208
  }
197
209
 
198
210
  // Layout child at offset position
199
211
  const childRect: Rect = {
200
- x: rect.x - scrollX,
201
- y: rect.y - scrollY,
212
+ x: layoutRect.x - scrollX,
213
+ y: layoutRect.y - scrollY,
202
214
  w: this.contentWidth,
203
215
  h: this.contentHeight,
204
216
  }
@@ -210,8 +222,7 @@ export class ScrollHost extends SingleChildHost {
210
222
  const { x, y, w, h } = this.rect
211
223
 
212
224
  // Fill background (inherit from parent if not explicitly set)
213
- const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
214
- buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
225
+ fillRectWithInheritedBg(buffer, palette, { x, y, w, h }, this.bg, this.parent)
215
226
 
216
227
  // Render children with clipping
217
228
  buffer.withClip(x, y, w, h, () => {
@@ -273,9 +284,7 @@ export class ScrollHost extends SingleChildHost {
273
284
  override updateProps(props: Record<string, unknown>): void {
274
285
  super.updateProps(props)
275
286
  // Scroll is greedy by default unless explicitly set to false
276
- if (!("greedy" in props)) {
277
- this.greedy = 1
278
- }
287
+ this.applyGreedyDefault(props, 1)
279
288
  if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
280
289
  if (props.offset !== undefined) this.offset = props.offset as number
281
290
  if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
@@ -286,6 +295,7 @@ export class ScrollHost extends SingleChildHost {
286
295
  this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
287
296
  this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
288
297
  this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
298
+ this.onEffectiveOffsetX = props.onEffectiveOffsetX as ScrollProps["onEffectiveOffsetX"]
289
299
  this.onRect = props.onRect as ScrollProps["onRect"]
290
300
  }
291
301
  }
@@ -37,9 +37,7 @@ export class SpacerHost extends BaseHost {
37
37
  override updateProps(props: Record<string, unknown>): void {
38
38
  super.updateProps(props)
39
39
  // Spacer is greedy by default unless explicitly set to false
40
- if (!("greedy" in props)) {
41
- this.greedy = 1
42
- }
40
+ this.applyGreedyDefault(props, 1)
43
41
  if (props.minWidth !== undefined) this.minWidth = props.minWidth as number
44
42
  if (props.minHeight !== undefined) this.minHeight = props.minHeight as number
45
43
  }