@effect-tui/react 0.12.3 → 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 (170) hide show
  1. package/README.md +31 -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/dev.d.ts +22 -32
  13. package/dist/src/dev.d.ts.map +1 -1
  14. package/dist/src/dev.js +42 -96
  15. package/dist/src/dev.js.map +1 -1
  16. package/dist/src/hooks/index.d.ts +3 -0
  17. package/dist/src/hooks/index.d.ts.map +1 -1
  18. package/dist/src/hooks/index.js +1 -0
  19. package/dist/src/hooks/index.js.map +1 -1
  20. package/dist/src/hooks/use-mouse.d.ts +10 -1
  21. package/dist/src/hooks/use-mouse.d.ts.map +1 -1
  22. package/dist/src/hooks/use-mouse.js +10 -2
  23. package/dist/src/hooks/use-mouse.js.map +1 -1
  24. package/dist/src/hooks/use-paste.d.ts +13 -3
  25. package/dist/src/hooks/use-paste.d.ts.map +1 -1
  26. package/dist/src/hooks/use-paste.js +15 -5
  27. package/dist/src/hooks/use-paste.js.map +1 -1
  28. package/dist/src/hooks/use-scroll.d.ts +59 -24
  29. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  30. package/dist/src/hooks/use-scroll.js +238 -79
  31. package/dist/src/hooks/use-scroll.js.map +1 -1
  32. package/dist/src/hooks/use-shortcut.d.ts +16 -0
  33. package/dist/src/hooks/use-shortcut.d.ts.map +1 -0
  34. package/dist/src/hooks/use-shortcut.js +29 -0
  35. package/dist/src/hooks/use-shortcut.js.map +1 -0
  36. package/dist/src/hosts/base.d.ts +16 -0
  37. package/dist/src/hosts/base.d.ts.map +1 -1
  38. package/dist/src/hosts/base.js +30 -0
  39. package/dist/src/hosts/base.js.map +1 -1
  40. package/dist/src/hosts/box.d.ts.map +1 -1
  41. package/dist/src/hosts/box.js +7 -8
  42. package/dist/src/hosts/box.js.map +1 -1
  43. package/dist/src/hosts/canvas.d.ts.map +1 -1
  44. package/dist/src/hosts/canvas.js +5 -3
  45. package/dist/src/hosts/canvas.js.map +1 -1
  46. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  47. package/dist/src/hosts/codeblock.js +5 -4
  48. package/dist/src/hosts/codeblock.js.map +1 -1
  49. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  50. package/dist/src/hosts/flex-container.js +5 -8
  51. package/dist/src/hosts/flex-container.js.map +1 -1
  52. package/dist/src/hosts/index.d.ts +1 -1
  53. package/dist/src/hosts/index.d.ts.map +1 -1
  54. package/dist/src/hosts/index.js +2 -3
  55. package/dist/src/hosts/index.js.map +1 -1
  56. package/dist/src/hosts/overlay-item.js +2 -2
  57. package/dist/src/hosts/overlay-item.js.map +1 -1
  58. package/dist/src/hosts/overlay.d.ts.map +1 -1
  59. package/dist/src/hosts/overlay.js +6 -11
  60. package/dist/src/hosts/overlay.js.map +1 -1
  61. package/dist/src/hosts/scroll.d.ts +8 -0
  62. package/dist/src/hosts/scroll.d.ts.map +1 -1
  63. package/dist/src/hosts/scroll.js +50 -26
  64. package/dist/src/hosts/scroll.js.map +1 -1
  65. package/dist/src/hosts/spacer.d.ts.map +1 -1
  66. package/dist/src/hosts/spacer.js +1 -3
  67. package/dist/src/hosts/spacer.js.map +1 -1
  68. package/dist/src/hosts/text.d.ts +24 -45
  69. package/dist/src/hosts/text.d.ts.map +1 -1
  70. package/dist/src/hosts/text.js +69 -215
  71. package/dist/src/hosts/text.js.map +1 -1
  72. package/dist/src/hosts/zstack.d.ts.map +1 -1
  73. package/dist/src/hosts/zstack.js +4 -10
  74. package/dist/src/hosts/zstack.js.map +1 -1
  75. package/dist/src/index.d.ts +6 -4
  76. package/dist/src/index.d.ts.map +1 -1
  77. package/dist/src/index.js +3 -2
  78. package/dist/src/index.js.map +1 -1
  79. package/dist/src/reconciler/types.d.ts +2 -0
  80. package/dist/src/reconciler/types.d.ts.map +1 -1
  81. package/dist/src/renderer/core/FrameBuilder.d.ts.map +1 -1
  82. package/dist/src/renderer/core/FrameBuilder.js +2 -0
  83. package/dist/src/renderer/core/FrameBuilder.js.map +1 -1
  84. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  85. package/dist/src/renderer/input/InputProcessor.js +13 -3
  86. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  87. package/dist/src/renderer/lifecycle/EventBus.d.ts +2 -2
  88. package/dist/src/renderer/lifecycle/EventBus.d.ts.map +1 -1
  89. package/dist/src/renderer/lifecycle/EventBus.js +13 -1
  90. package/dist/src/renderer/lifecycle/EventBus.js.map +1 -1
  91. package/dist/src/renderer/lifecycle/RenderCache.d.ts +3 -0
  92. package/dist/src/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  93. package/dist/src/renderer/lifecycle/RenderCache.js +9 -0
  94. package/dist/src/renderer/lifecycle/RenderCache.js.map +1 -0
  95. package/dist/src/renderer/lifecycle/index.d.ts +1 -0
  96. package/dist/src/renderer/lifecycle/index.d.ts.map +1 -1
  97. package/dist/src/renderer/lifecycle/index.js +1 -0
  98. package/dist/src/renderer/lifecycle/index.js.map +1 -1
  99. package/dist/src/renderer-types.d.ts +9 -2
  100. package/dist/src/renderer-types.d.ts.map +1 -1
  101. package/dist/src/renderer.d.ts +13 -2
  102. package/dist/src/renderer.d.ts.map +1 -1
  103. package/dist/src/renderer.js +42 -11
  104. package/dist/src/renderer.js.map +1 -1
  105. package/dist/src/shortcuts.d.ts +15 -0
  106. package/dist/src/shortcuts.d.ts.map +1 -0
  107. package/dist/src/shortcuts.js +149 -0
  108. package/dist/src/shortcuts.js.map +1 -0
  109. package/dist/src/test/mock-streams.d.ts.map +1 -1
  110. package/dist/src/test/mock-streams.js +0 -3
  111. package/dist/src/test/mock-streams.js.map +1 -1
  112. package/dist/src/test/render-tui.d.ts.map +1 -1
  113. package/dist/src/test/render-tui.js +1 -0
  114. package/dist/src/test/render-tui.js.map +1 -1
  115. package/dist/src/utils/alignment.d.ts +4 -0
  116. package/dist/src/utils/alignment.d.ts.map +1 -1
  117. package/dist/src/utils/alignment.js +12 -0
  118. package/dist/src/utils/alignment.js.map +1 -1
  119. package/dist/src/utils/index.d.ts +3 -2
  120. package/dist/src/utils/index.d.ts.map +1 -1
  121. package/dist/src/utils/index.js +3 -2
  122. package/dist/src/utils/index.js.map +1 -1
  123. package/dist/src/utils/styles.d.ts +6 -1
  124. package/dist/src/utils/styles.d.ts.map +1 -1
  125. package/dist/src/utils/styles.js +9 -0
  126. package/dist/src/utils/styles.js.map +1 -1
  127. package/dist/src/utils/text-wrap.d.ts +10 -0
  128. package/dist/src/utils/text-wrap.d.ts.map +1 -0
  129. package/dist/src/utils/text-wrap.js +64 -0
  130. package/dist/src/utils/text-wrap.js.map +1 -0
  131. package/dist/tsconfig.tsbuildinfo +1 -1
  132. package/jsx-runtime.ts +1 -2
  133. package/package.json +2 -2
  134. package/src/components/Markdown.tsx +7 -7
  135. package/src/components/MultilineTextInput.tsx +14 -0
  136. package/src/components/TextInput.tsx +18 -0
  137. package/src/dev.tsx +59 -107
  138. package/src/hooks/index.ts +3 -0
  139. package/src/hooks/use-mouse.ts +19 -3
  140. package/src/hooks/use-paste.ts +24 -5
  141. package/src/hooks/use-scroll.ts +345 -105
  142. package/src/hooks/use-shortcut.ts +54 -0
  143. package/src/hosts/base.ts +35 -0
  144. package/src/hosts/box.ts +7 -8
  145. package/src/hosts/canvas.ts +5 -3
  146. package/src/hosts/codeblock.ts +5 -4
  147. package/src/hosts/flex-container.ts +5 -7
  148. package/src/hosts/index.ts +1 -4
  149. package/src/hosts/overlay-item.ts +2 -2
  150. package/src/hosts/overlay.ts +6 -12
  151. package/src/hosts/scroll.ts +55 -26
  152. package/src/hosts/spacer.ts +1 -3
  153. package/src/hosts/text.ts +89 -256
  154. package/src/hosts/zstack.ts +4 -11
  155. package/src/index.ts +19 -6
  156. package/src/reconciler/types.ts +3 -0
  157. package/src/renderer/core/FrameBuilder.ts +3 -0
  158. package/src/renderer/input/InputProcessor.ts +13 -3
  159. package/src/renderer/lifecycle/EventBus.ts +14 -4
  160. package/src/renderer/lifecycle/RenderCache.ts +13 -0
  161. package/src/renderer/lifecycle/index.ts +1 -0
  162. package/src/renderer-types.ts +10 -2
  163. package/src/renderer.ts +85 -14
  164. package/src/shortcuts.ts +180 -0
  165. package/src/test/mock-streams.ts +0 -3
  166. package/src/test/render-tui.ts +1 -0
  167. package/src/utils/alignment.ts +18 -0
  168. package/src/utils/index.ts +3 -1
  169. package/src/utils/styles.ts +18 -1
  170. package/src/utils/text-wrap.ts +66 -0
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 {
@@ -28,6 +28,8 @@ export interface ScrollProps extends CommonProps {
28
28
  onViewportSize?: (width: number, height: number) => void
29
29
  /** Called when effective offset changes (for syncing with useScroll when sticky adjusts) */
30
30
  onEffectiveOffset?: (offset: number) => void
31
+ /** Called when effective horizontal offset changes */
32
+ onEffectiveOffsetX?: (offsetX: number) => void
31
33
  /** Called when layout rect changes (for hit testing) */
32
34
  onRect?: (x: number, y: number, w: number, h: number) => void
33
35
  }
@@ -46,18 +48,24 @@ export class ScrollHost extends SingleChildHost {
46
48
  onContentSize?: (width: number, height: number) => void
47
49
  onViewportSize?: (width: number, height: number) => void
48
50
  onEffectiveOffset?: (offset: number) => void
51
+ onEffectiveOffsetX?: (offsetX: number) => void
49
52
  onRect?: (x: number, y: number, w: number, h: number) => void
50
53
 
51
54
  // Measured content dimensions (full size before clipping)
52
55
  private contentWidth = 0
53
56
  private contentHeight = 0
54
57
  // Track last reported sizes to avoid redundant callbacks
55
- private lastReportedContentW = 0
56
- private lastReportedContentH = 0
58
+ private lastReportedContentW = -1
59
+ private lastReportedContentH = -1
60
+ private lastViewportW = -1
61
+ private lastViewportH = -1
57
62
  private lastRectX = -1
58
63
  private lastRectY = -1
64
+ private lastRectW = -1
65
+ private lastRectH = -1
59
66
  // Track if we were at end (for sticky behavior)
60
67
  private wasAtEnd = true
68
+ private wasAtEndX = true
61
69
  // Effective offset after sticky adjustment (used for rendering)
62
70
  private effectiveOffset = 0
63
71
  private effectiveOffsetX = 0
@@ -100,7 +108,7 @@ export class ScrollHost extends SingleChildHost {
100
108
  }
101
109
 
102
110
  override layout(rect: Rect): void {
103
- super.layout(rect)
111
+ const layoutRect = this.layoutWithConstraints(rect)
104
112
 
105
113
  // Report content size if changed (deferred from measure() to keep it pure)
106
114
  if (this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH) {
@@ -110,33 +118,41 @@ export class ScrollHost extends SingleChildHost {
110
118
  }
111
119
 
112
120
  // Report viewport size if changed (for useScroll hook)
113
- // Always report on every layout - React may have recreated the hook with stale refs
114
- if (this.onViewportSize) {
115
- 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)
116
125
  }
117
126
 
118
127
  // Report rect if position changed (for hit testing)
119
- if (rect.x !== this.lastRectX || rect.y !== this.lastRectY) {
120
- this.lastRectX = rect.x
121
- this.lastRectY = rect.y
122
- 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)
123
139
  }
124
140
 
125
141
  const child = this.child
126
142
  if (!child) return
127
143
 
128
144
  // Calculate max scroll offsets
129
- const maxScrollY = Math.max(0, this.contentHeight - rect.h)
130
- 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)
131
147
 
132
148
  // Start with the offset from props (controlled by useScroll)
133
149
  let scrollY = this.offset
134
150
  let scrollX = this.offsetX
135
151
 
136
- // Sticky scroll logic:
152
+ // Sticky scroll logic (vertical):
137
153
  // - If user manually scrolled away from end (offset < effectiveOffset), unstick
138
154
  // - If at end (or was at end and content grew), stay stuck
139
- if (this.sticky) {
155
+ if (this.sticky && (this.axis === "vertical" || this.axis === "both")) {
140
156
  // Detect if user scrolled away (offset prop is less than where we rendered)
141
157
  const userScrolledAway = this.offset < this.effectiveOffset - 1
142
158
 
@@ -149,8 +165,20 @@ export class ScrollHost extends SingleChildHost {
149
165
  }
150
166
  }
151
167
 
168
+ // Sticky scroll logic (horizontal)
169
+ if (this.sticky && (this.axis === "horizontal" || this.axis === "both")) {
170
+ const userScrolledAway = this.offsetX < this.effectiveOffsetX - 1
171
+
172
+ if (userScrolledAway) {
173
+ this.wasAtEndX = false
174
+ } else if (this.wasAtEndX) {
175
+ scrollX = maxScrollX
176
+ }
177
+ }
178
+
152
179
  // Track if we're at end for next frame
153
180
  this.wasAtEnd = scrollY >= maxScrollY - 1
181
+ this.wasAtEndX = scrollX >= maxScrollX - 1
154
182
 
155
183
  // Clamp offsets
156
184
  scrollY = Math.max(0, Math.min(maxScrollY, scrollY))
@@ -161,25 +189,28 @@ export class ScrollHost extends SingleChildHost {
161
189
  if (scrollY !== this.effectiveOffset || scrollY !== this.offset) {
162
190
  this.onEffectiveOffset?.(scrollY)
163
191
  }
192
+ if (scrollX !== this.effectiveOffsetX || scrollX !== this.offsetX) {
193
+ this.onEffectiveOffsetX?.(scrollX)
194
+ }
164
195
  this.effectiveOffset = scrollY
165
196
  this.effectiveOffsetX = scrollX
166
197
 
167
198
  // Handle alignment when content is smaller than viewport
168
199
  if (this.align === "end") {
169
- if (this.contentHeight < rect.h && (this.axis === "vertical" || this.axis === "both")) {
200
+ if (this.contentHeight < layoutRect.h && (this.axis === "vertical" || this.axis === "both")) {
170
201
  // Align to bottom
171
- scrollY = -(rect.h - this.contentHeight)
202
+ scrollY = -(layoutRect.h - this.contentHeight)
172
203
  }
173
- if (this.contentWidth < rect.w && (this.axis === "horizontal" || this.axis === "both")) {
204
+ if (this.contentWidth < layoutRect.w && (this.axis === "horizontal" || this.axis === "both")) {
174
205
  // Align to right
175
- scrollX = -(rect.w - this.contentWidth)
206
+ scrollX = -(layoutRect.w - this.contentWidth)
176
207
  }
177
208
  }
178
209
 
179
210
  // Layout child at offset position
180
211
  const childRect: Rect = {
181
- x: rect.x - scrollX,
182
- y: rect.y - scrollY,
212
+ x: layoutRect.x - scrollX,
213
+ y: layoutRect.y - scrollY,
183
214
  w: this.contentWidth,
184
215
  h: this.contentHeight,
185
216
  }
@@ -191,8 +222,7 @@ export class ScrollHost extends SingleChildHost {
191
222
  const { x, y, w, h } = this.rect
192
223
 
193
224
  // Fill background (inherit from parent if not explicitly set)
194
- const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
195
- buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
225
+ fillRectWithInheritedBg(buffer, palette, { x, y, w, h }, this.bg, this.parent)
196
226
 
197
227
  // Render children with clipping
198
228
  buffer.withClip(x, y, w, h, () => {
@@ -254,9 +284,7 @@ export class ScrollHost extends SingleChildHost {
254
284
  override updateProps(props: Record<string, unknown>): void {
255
285
  super.updateProps(props)
256
286
  // Scroll is greedy by default unless explicitly set to false
257
- if (!("greedy" in props)) {
258
- this.greedy = 1
259
- }
287
+ this.applyGreedyDefault(props, 1)
260
288
  if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
261
289
  if (props.offset !== undefined) this.offset = props.offset as number
262
290
  if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
@@ -267,6 +295,7 @@ export class ScrollHost extends SingleChildHost {
267
295
  this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
268
296
  this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
269
297
  this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
298
+ this.onEffectiveOffsetX = props.onEffectiveOffsetX as ScrollProps["onEffectiveOffsetX"]
270
299
  this.onRect = props.onRect as ScrollProps["onRect"]
271
300
  }
272
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
  }