@effect-tui/react 0.6.3 → 0.8.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 (59) hide show
  1. package/dist/jsx-runtime.d.ts +4 -1
  2. package/dist/jsx-runtime.d.ts.map +1 -1
  3. package/dist/src/components/ListView.d.ts +3 -1
  4. package/dist/src/components/ListView.d.ts.map +1 -1
  5. package/dist/src/components/ListView.js +38 -11
  6. package/dist/src/components/ListView.js.map +1 -1
  7. package/dist/src/console/ConsoleCapture.d.ts +19 -0
  8. package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
  9. package/dist/src/console/ConsoleCapture.js +132 -9
  10. package/dist/src/console/ConsoleCapture.js.map +1 -1
  11. package/dist/src/hooks/use-scroll.d.ts +5 -3
  12. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  13. package/dist/src/hooks/use-scroll.js +4 -2
  14. package/dist/src/hooks/use-scroll.js.map +1 -1
  15. package/dist/src/hosts/index.d.ts +1 -1
  16. package/dist/src/hosts/index.d.ts.map +1 -1
  17. package/dist/src/hosts/index.js +3 -2
  18. package/dist/src/hosts/index.js.map +1 -1
  19. package/dist/src/hosts/scroll.d.ts +5 -0
  20. package/dist/src/hosts/scroll.d.ts.map +1 -1
  21. package/dist/src/hosts/scroll.js +10 -0
  22. package/dist/src/hosts/scroll.js.map +1 -1
  23. package/dist/src/hosts/text.d.ts +48 -1
  24. package/dist/src/hosts/text.d.ts.map +1 -1
  25. package/dist/src/hosts/text.js +200 -5
  26. package/dist/src/hosts/text.js.map +1 -1
  27. package/dist/src/index.d.ts +1 -1
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/remote/Procedures.d.ts +11 -0
  30. package/dist/src/remote/Procedures.d.ts.map +1 -1
  31. package/dist/src/remote/Procedures.js +17 -1
  32. package/dist/src/remote/Procedures.js.map +1 -1
  33. package/dist/src/remote/Router.d.ts +12 -1
  34. package/dist/src/remote/Router.d.ts.map +1 -1
  35. package/dist/src/remote/Router.js +1 -0
  36. package/dist/src/remote/Router.js.map +1 -1
  37. package/dist/src/remote/index.d.ts.map +1 -1
  38. package/dist/src/remote/index.js +14 -0
  39. package/dist/src/remote/index.js.map +1 -1
  40. package/dist/src/renderer.d.ts.map +1 -1
  41. package/dist/src/renderer.js +9 -1
  42. package/dist/src/renderer.js.map +1 -1
  43. package/dist/src/test/render-tui.js +2 -2
  44. package/dist/src/test/render-tui.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/jsx-runtime.ts +2 -1
  47. package/package.json +2 -2
  48. package/src/components/ListView.tsx +50 -13
  49. package/src/console/ConsoleCapture.ts +151 -9
  50. package/src/hooks/use-scroll.ts +9 -5
  51. package/src/hosts/index.ts +13 -2
  52. package/src/hosts/scroll.ts +13 -0
  53. package/src/hosts/text.ts +242 -5
  54. package/src/index.ts +1 -1
  55. package/src/remote/Procedures.ts +19 -1
  56. package/src/remote/Router.ts +14 -1
  57. package/src/remote/index.ts +15 -1
  58. package/src/renderer.ts +9 -1
  59. package/src/test/render-tui.ts +2 -2
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 { StyledTextProps, TextProps } from "./src/hosts/text.js"
14
+ import type { SpanProps, StyledTextProps, 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,6 +40,7 @@ export declare namespace JSX {
40
40
  export interface IntrinsicElements extends React.JSX.IntrinsicElements {
41
41
  // Our custom TUI elements (override any React conflicts)
42
42
  text: TextProps & { children?: React.ReactNode }
43
+ span: SpanProps & { children?: React.ReactNode }
43
44
  styledtext: StyledTextProps
44
45
  spacer: SpacerProps
45
46
  vstack: VStackProps & { children?: React.ReactNode; __static?: boolean }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.6.3",
3
+ "version": "0.8.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.6.3",
86
+ "@effect-tui/core": "^0.8.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -1,6 +1,7 @@
1
1
  // ListView.tsx — Virtualized selection-aware scrolling list
2
2
  import type { ReactNode } from "react"
3
- import { useEffect, useRef } from "react"
3
+ import { useEffect, useLayoutEffect, useRef } from "react"
4
+ import { useMouse } from "../hooks/use-mouse.js"
4
5
  import { useScroll } from "../hooks/use-scroll.js"
5
6
 
6
7
  export interface ListViewProps<T> {
@@ -22,6 +23,8 @@ export interface ListViewProps<T> {
22
23
  emptyContent?: ReactNode
23
24
  /** Number of extra items to render above/below viewport for smooth scrolling (default: 3) */
24
25
  overscan?: number
26
+ /** Called when an item is clicked. Receives the item index. */
27
+ onItemClick?: (index: number) => void
25
28
  /** @internal */
26
29
  __debugViewportMeasured?: boolean
27
30
  /** @internal */
@@ -78,6 +81,7 @@ export function ListView<T>({
78
81
  showScrollbar = true,
79
82
  emptyContent,
80
83
  overscan = 3,
84
+ onItemClick,
81
85
  __debugViewportMeasured,
82
86
  __debugViewportSize,
83
87
  __debugContentSize,
@@ -92,21 +96,48 @@ export function ListView<T>({
92
96
  const viewportMeasured = __debugViewportMeasured ?? state.viewportMeasured
93
97
  const viewportSize = __debugViewportSize ?? state.viewportSize
94
98
 
99
+ // Track scroll container rect for hit testing
100
+ const rectRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
101
+
102
+ // Handle mouse clicks on items
103
+ useMouse(
104
+ (mouse) => {
105
+ if (!onItemClick || !rectRef.current) return
106
+ const { x, y, w, h } = rectRef.current
107
+
108
+ // Check if click is inside the scroll container
109
+ if (mouse.x < x || mouse.x >= x + w || mouse.y < y || mouse.y >= y + h) return
110
+
111
+ // Calculate which item was clicked
112
+ const localY = mouse.y - y
113
+ const contentY = localY + state.offset
114
+ const clickedIndex = Math.floor(contentY / itemHeight)
115
+
116
+ // Validate index is in range
117
+ if (clickedIndex >= 0 && clickedIndex < items.length) {
118
+ onItemClick(clickedIndex)
119
+ }
120
+ },
121
+ { action: "press", button: "left" },
122
+ )
123
+
95
124
  // Track previous selection to detect changes
96
125
  const prevSelectedRef = useRef(selectedIndex)
97
126
 
98
127
  // Scroll to keep selection visible when it changes
99
- useEffect(() => {
128
+ // useLayoutEffect runs before paint - prevents visible "jump" when navigating
129
+ useLayoutEffect(() => {
100
130
  if (selectedIndex !== prevSelectedRef.current) {
101
131
  prevSelectedRef.current = selectedIndex
102
- scrollToVisible(selectedIndex, itemHeight, scrollPadding)
132
+ // Pass totalHeight to avoid stale contentSize issues when jumping to end
133
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
103
134
  }
104
- }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible])
135
+ }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible, totalHeight])
105
136
 
106
137
  // Also scroll on initial render if selection is not 0
107
138
  useEffect(() => {
108
139
  if (selectedIndex > 0) {
109
- scrollToVisible(selectedIndex, itemHeight, scrollPadding)
140
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
110
141
  }
111
142
  // Only run on mount
112
143
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -117,25 +148,31 @@ export function ListView<T>({
117
148
  }
118
149
 
119
150
  // Calculate visible range for virtualization
151
+ // Before viewport is measured, render a reasonable default (not all items!)
120
152
  const startIndex = viewportMeasured
121
153
  ? Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
122
154
  : 0
123
155
  const endIndex = viewportMeasured
124
156
  ? Math.min(items.length, Math.ceil((state.offset + viewportSize) / itemHeight) + overscan)
125
- : items.length
157
+ : Math.min(items.length, 50)
126
158
 
127
159
  // Calculate spacer heights for virtual scrolling
128
- const topSpacerHeight = viewportMeasured ? startIndex * itemHeight : 0
129
- const bottomSpacerHeight = viewportMeasured ? Math.max(0, totalHeight - endIndex * itemHeight) : 0
160
+ const topSpacerHeight = startIndex * itemHeight
161
+ const bottomSpacerHeight = Math.max(0, totalHeight - endIndex * itemHeight)
130
162
 
131
163
  // Get visible slice of items
132
164
  const visibleItems = items.slice(startIndex, endIndex)
133
165
 
166
+ // Handler to track scroll container rect for click detection
167
+ const handleRect = (x: number, y: number, w: number, h: number) => {
168
+ rectRef.current = { x, y, w, h }
169
+ }
170
+
134
171
  return (
135
- <scroll {...scrollProps} showScrollbar={showScrollbar}>
172
+ <scroll {...scrollProps} showScrollbar={showScrollbar} onRect={handleRect}>
136
173
  <vstack>
137
- {/* Virtual top spacer */}
138
- {topSpacerHeight > 0 && <spacer minHeight={topSpacerHeight} />}
174
+ {/* Virtual top spacer - always render to maintain structure */}
175
+ <box key="top-spacer" height={topSpacerHeight} />
139
176
 
140
177
  {/* Render only visible items */}
141
178
  {visibleItems.map((item, i) => {
@@ -147,8 +184,8 @@ export function ListView<T>({
147
184
  )
148
185
  })}
149
186
 
150
- {/* Virtual bottom spacer */}
151
- {bottomSpacerHeight > 0 && <spacer minHeight={bottomSpacerHeight} />}
187
+ {/* Virtual bottom spacer - always render to maintain structure */}
188
+ <box key="bottom-spacer" height={bottomSpacerHeight} />
152
189
  </vstack>
153
190
  </scroll>
154
191
  )
@@ -1,5 +1,6 @@
1
1
  // Console capture singleton for TUI debugging
2
- // Intercepts console.log/info/warn/error/debug and stores entries for display
2
+ // Intercepts console.log/info/warn/error/debug AND process.stdout/stderr.write
3
+ // to capture ALL output including Effect.log, third-party libraries, etc.
3
4
 
4
5
  import { Console } from "node:console"
5
6
  import { EventEmitter } from "node:events"
@@ -105,17 +106,39 @@ export interface ConsoleCaptureEvents {
105
106
  error: (entry: LogEntry) => void
106
107
  }
107
108
 
109
+ // Store the ORIGINAL stdout/stderr write functions at module load time
110
+ // This ensures the renderer can always write to the terminal even when capture is active
111
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout)
112
+ const originalStderrWrite = process.stderr.write.bind(process.stderr)
113
+
108
114
  export class ConsoleCapture extends EventEmitter {
109
115
  private entries: LogEntry[] = []
110
116
  private originalConsole: typeof console | null = null
111
117
  private active = false
112
118
  private maxEntries: number
119
+ // Track if current write is from our console methods (to avoid double-capture)
120
+ private inConsoleMethod = false
121
+ // Track if current write is from the TUI renderer (should bypass capture)
122
+ private inRendererWrite = false
113
123
 
114
124
  constructor(options?: { maxEntries?: number }) {
115
125
  super()
116
126
  this.maxEntries = options?.maxEntries ?? 1000
117
127
  }
118
128
 
129
+ /**
130
+ * Write directly to stdout, bypassing capture.
131
+ * Used by the TUI renderer to output to terminal.
132
+ */
133
+ writeToTerminal(data: string | Uint8Array): boolean {
134
+ this.inRendererWrite = true
135
+ try {
136
+ return originalStdoutWrite(data)
137
+ } finally {
138
+ this.inRendererWrite = false
139
+ }
140
+ }
141
+
119
142
  get isActive(): boolean {
120
143
  return this.active
121
144
  }
@@ -134,6 +157,45 @@ export class ConsoleCapture extends EventEmitter {
134
157
  // Store original console
135
158
  this.originalConsole = globalThis.console
136
159
 
160
+ // Intercept stdout.write - captures Effect.log, printf, etc.
161
+ process.stdout.write = ((
162
+ chunk: Uint8Array | string,
163
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
164
+ callback?: (err?: Error | null) => void,
165
+ ): boolean => {
166
+ // If this write is from renderer or console methods, pass through
167
+ if (this.inRendererWrite) {
168
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
169
+ }
170
+ // Capture if not from our console methods (avoid double-capture)
171
+ if (!this.inConsoleMethod) {
172
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
173
+ this.captureRawOutput(text, "stdout")
174
+ }
175
+ // Don't write to real stdout - TUI owns the terminal
176
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
177
+ if (cb) cb()
178
+ return true
179
+ }) as typeof process.stdout.write
180
+
181
+ // Intercept stderr.write
182
+ process.stderr.write = ((
183
+ chunk: Uint8Array | string,
184
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
185
+ callback?: (err?: Error | null) => void,
186
+ ): boolean => {
187
+ if (this.inRendererWrite) {
188
+ return originalStderrWrite(chunk, encodingOrCallback as BufferEncoding, callback)
189
+ }
190
+ if (!this.inConsoleMethod) {
191
+ const text = typeof chunk === "string" ? chunk : chunk.toString()
192
+ this.captureRawOutput(text, "stderr")
193
+ }
194
+ const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback
195
+ if (cb) cb()
196
+ return true
197
+ }) as typeof process.stdout.write
198
+
137
199
  // Create mock streams for the new console
138
200
  const mockStdout = new CapturedWritableStream()
139
201
  const mockStderr = new CapturedWritableStream()
@@ -197,37 +259,95 @@ export class ConsoleCapture extends EventEmitter {
197
259
  deactivate(): void {
198
260
  if (!this.active || !this.originalConsole) return
199
261
 
262
+ // Restore original stdout/stderr write methods
263
+ process.stdout.write = originalStdoutWrite
264
+ process.stderr.write = originalStderrWrite
265
+
200
266
  globalThis.console = this.originalConsole
201
267
  this.originalConsole = null
202
268
  this.active = false
203
269
  }
204
270
 
205
271
  private appendToConsole(level: LogLevel, args: unknown[]): void {
206
- const location = captureCallerLocation()
207
- const message = util.format(...args)
272
+ // Mark that we're in a console method to avoid double-capture from stdout/stderr
273
+ this.inConsoleMethod = true
274
+ try {
275
+ const location = captureCallerLocation()
276
+ const message = util.format(...args)
277
+
278
+ const entry: LogEntry = {
279
+ timestamp: new Date(),
280
+ level,
281
+ message,
282
+ args,
283
+ location,
284
+ }
285
+
286
+ // Enforce max entries
287
+ if (this.entries.length >= this.maxEntries) {
288
+ this.entries.shift()
289
+ }
290
+ this.entries.push(entry)
291
+
292
+ // Emit events
293
+ this.emit("entry", entry)
294
+ // Only emit error event if there are listeners (avoid unhandled error exception)
295
+ if (level === "ERROR" && this.listenerCount("error") > 0) {
296
+ this.emit("error", entry)
297
+ }
298
+ } finally {
299
+ this.inConsoleMethod = false
300
+ }
301
+ }
302
+
303
+ // Capture raw output from stdout/stderr (for Effect.log, third-party libs, etc.)
304
+ private captureRawOutput(text: string, stream: "stdout" | "stderr"): void {
305
+ // Skip empty output
306
+ if (!text.trim()) return
307
+
308
+ // Parse Effect log format: timestamp level fiber message
309
+ // Example: "timestamp=2024-01-15T10:30:00.000Z level=INFO fiber=#0 message=Hello"
310
+ // Or the pretty format with ANSI colors
311
+ const level = this.detectLogLevel(text, stream)
312
+
313
+ // Strip ANSI codes for the stored message
314
+ const cleanText = text.replace(/\x1b\[[0-9;]*m/g, "").trim()
208
315
 
209
316
  const entry: LogEntry = {
210
317
  timestamp: new Date(),
211
318
  level,
212
- message,
213
- args,
214
- location,
319
+ message: cleanText,
320
+ args: [text], // Keep original with ANSI for potential future use
321
+ // No location for raw output - we can't get stack trace here
215
322
  }
216
323
 
217
- // Enforce max entries
218
324
  if (this.entries.length >= this.maxEntries) {
219
325
  this.entries.shift()
220
326
  }
221
327
  this.entries.push(entry)
222
328
 
223
- // Emit events
224
329
  this.emit("entry", entry)
225
- // Only emit error event if there are listeners (avoid unhandled error exception)
226
330
  if (level === "ERROR" && this.listenerCount("error") > 0) {
227
331
  this.emit("error", entry)
228
332
  }
229
333
  }
230
334
 
335
+ // Detect log level from raw output text
336
+ private detectLogLevel(text: string, stream: "stdout" | "stderr"): LogLevel {
337
+ const upperText = text.toUpperCase()
338
+
339
+ // Effect log format detection
340
+ if (upperText.includes("LEVEL=ERROR") || upperText.includes("[ERROR]")) return "ERROR"
341
+ if (upperText.includes("LEVEL=WARN") || upperText.includes("[WARN]")) return "WARN"
342
+ if (upperText.includes("LEVEL=INFO") || upperText.includes("[INFO]")) return "INFO"
343
+ if (upperText.includes("LEVEL=DEBUG") || upperText.includes("[DEBUG]")) return "DEBUG"
344
+
345
+ // stderr is typically error output
346
+ if (stream === "stderr") return "ERROR"
347
+
348
+ return "LOG"
349
+ }
350
+
231
351
  // Also expose write to original console for debugging the capture itself
232
352
  writeToOriginal(level: LogLevel, ...args: unknown[]): void {
233
353
  if (!this.originalConsole) return
@@ -264,6 +384,28 @@ export function getConsoleCapture(): ConsoleCapture {
264
384
  return instance
265
385
  }
266
386
 
387
+ /**
388
+ * Write directly to the terminal, bypassing console capture.
389
+ * Used by the TUI renderer to output to terminal without being captured.
390
+ */
391
+ export function writeToTerminal(data: string | Uint8Array): boolean {
392
+ return originalStdoutWrite(data)
393
+ }
394
+
395
+ /**
396
+ * Create a wrapped stdout that bypasses console capture.
397
+ * Returns a write function compatible with TuiWriteStream.
398
+ */
399
+ export function createTerminalWriter(): typeof process.stdout.write {
400
+ return ((
401
+ chunk: Uint8Array | string,
402
+ encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
403
+ callback?: (err?: Error | null) => void,
404
+ ): boolean => {
405
+ return originalStdoutWrite(chunk, encodingOrCallback as BufferEncoding, callback)
406
+ }) as typeof process.stdout.write
407
+ }
408
+
267
409
  // Clean up on process exit
268
410
  process.on("exit", () => {
269
411
  if (instance?.isActive) {
@@ -104,9 +104,9 @@ export interface ScrollState {
104
104
  export interface UseScrollOptions {
105
105
  /** Scroll axis: "vertical" (default) or "horizontal" */
106
106
  axis?: "vertical" | "horizontal"
107
- /** Initial viewport size override (useful for tests) */
107
+ /** @internal Initial viewport size override (useful for tests) */
108
108
  initialViewportSize?: number
109
- /** Initial content size override (useful for tests) */
109
+ /** @internal Initial content size override (useful for tests) */
110
110
  initialContentSize?: number
111
111
  /** Initial scroll offset */
112
112
  initialOffset?: number
@@ -141,14 +141,16 @@ export interface UseScrollReturn {
141
141
  * @param position - The position (row/col index or pixel offset)
142
142
  * @param itemSize - Size of each item (default: 1 for row-based lists)
143
143
  * @param padding - Extra padding around the item (default: 0)
144
+ * @param totalSize - Optional known total content size (avoids stale state issues)
144
145
  */
145
- scrollToVisible: (position: number, itemSize?: number, padding?: number) => void
146
+ scrollToVisible: (position: number, itemSize?: number, padding?: number, totalSize?: number) => void
146
147
  /** Props to spread on <scroll> element */
147
148
  scrollProps: {
148
149
  offset: number
149
150
  axis: "vertical" | "horizontal"
150
151
  onContentSize: (width: number, height: number) => void
151
152
  onViewportSize: (width: number, height: number) => void
153
+ onRect?: (x: number, y: number, w: number, h: number) => void
152
154
  }
153
155
  }
154
156
 
@@ -340,10 +342,12 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
340
342
  // Scroll to make a position visible (for keeping selection in view)
341
343
  // Uses refs to avoid changing on every scroll - only triggers when selection changes
342
344
  const scrollToVisible = useCallback(
343
- (position: number, itemSize = 1, padding = 0) => {
345
+ (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
344
346
  const currentOffset = offsetRef.current
345
347
  const itemStart = position * itemSize
346
348
  const itemEnd = itemStart + itemSize
349
+ // Use provided totalSize if available (more accurate than potentially stale contentSize)
350
+ const effectiveContentSize = totalSize ?? contentSize
347
351
 
348
352
  // If item is above viewport, scroll up to show it
349
353
  if (itemStart < currentOffset + padding) {
@@ -351,7 +355,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
351
355
  }
352
356
  // If item is below viewport, scroll down to show it
353
357
  else if (itemEnd > currentOffset + viewportSize - padding) {
354
- const currentMaxOffset = Math.max(0, contentSize - viewportSize)
358
+ const currentMaxOffset = Math.max(0, effectiveContentSize - viewportSize)
355
359
  setOffset(Math.min(currentMaxOffset, itemEnd - viewportSize + padding))
356
360
  }
357
361
  },
@@ -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, StyledTextHost, TextHost } from "./text.js"
11
+ import { RawTextHost, SpanHost, StyledTextHost, TextHost } from "./text.js"
12
12
  import { VStackHost } from "./vstack.js"
13
13
  import { ZStackHost } from "./zstack.js"
14
14
 
@@ -22,7 +22,17 @@ export { OverlayItemHost, type OverlayItemProps } from "./overlay-item.js"
22
22
  export { ScrollHost, type ScrollProps } from "./scroll.js"
23
23
  export { SingleChildHost } from "./single-child.js"
24
24
  export { SpacerHost, type SpacerProps } from "./spacer.js"
25
- export { RawTextHost, StyledTextHost, TextHost, type StyledSpan, type StyledTextProps, type TextProps } from "./text.js"
25
+ export {
26
+ RawTextHost,
27
+ SpanHost,
28
+ StyledTextHost,
29
+ TextHost,
30
+ type SpanProps,
31
+ type SpanStyle,
32
+ type StyledSpan,
33
+ type StyledTextProps,
34
+ type TextProps,
35
+ } from "./text.js"
26
36
  export { VStackHost, type VStackProps } from "./vstack.js"
27
37
  export { ZStackHost, type ZStackProps } from "./zstack.js"
28
38
 
@@ -30,6 +40,7 @@ export { ZStackHost, type ZStackProps } from "./zstack.js"
30
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
41
  export const hostRegistry: Record<string, new (props: any, ctx: HostContext) => BaseHost> = {
32
42
  text: TextHost,
43
+ span: SpanHost,
33
44
  styledtext: StyledTextHost,
34
45
  spacer: SpacerHost,
35
46
  vstack: VStackHost,
@@ -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 layout rect changes (for hit testing) */
32
+ onRect?: (x: number, y: number, w: number, h: number) => void
31
33
  }
32
34
 
33
35
  export class ScrollHost extends SingleChildHost {
@@ -44,6 +46,7 @@ export class ScrollHost extends SingleChildHost {
44
46
  onContentSize?: (width: number, height: number) => void
45
47
  onViewportSize?: (width: number, height: number) => void
46
48
  onEffectiveOffset?: (offset: number) => void
49
+ onRect?: (x: number, y: number, w: number, h: number) => void
47
50
 
48
51
  // Measured content dimensions (full size before clipping)
49
52
  private contentWidth = 0
@@ -53,6 +56,8 @@ export class ScrollHost extends SingleChildHost {
53
56
  private lastViewportH = 0
54
57
  private lastReportedContentW = 0
55
58
  private lastReportedContentH = 0
59
+ private lastRectX = -1
60
+ private lastRectY = -1
56
61
  // Track if we were at end (for sticky behavior)
57
62
  private wasAtEnd = true
58
63
  // Effective offset after sticky adjustment (used for rendering)
@@ -113,6 +118,13 @@ export class ScrollHost extends SingleChildHost {
113
118
  this.onViewportSize?.(rect.w, rect.h)
114
119
  }
115
120
 
121
+ // Report rect if position changed (for hit testing)
122
+ if (rect.x !== this.lastRectX || rect.y !== this.lastRectY) {
123
+ this.lastRectX = rect.x
124
+ this.lastRectY = rect.y
125
+ this.onRect?.(rect.x, rect.y, rect.w, rect.h)
126
+ }
127
+
116
128
  const child = this.child
117
129
  if (!child) return
118
130
 
@@ -260,5 +272,6 @@ export class ScrollHost extends SingleChildHost {
260
272
  this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
261
273
  this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
262
274
  this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
275
+ this.onRect = props.onRect as ScrollProps["onRect"]
263
276
  }
264
277
  }