@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.
- package/dist/jsx-runtime.d.ts +4 -1
- package/dist/jsx-runtime.d.ts.map +1 -1
- package/dist/src/components/ListView.d.ts +3 -1
- package/dist/src/components/ListView.d.ts.map +1 -1
- package/dist/src/components/ListView.js +38 -11
- package/dist/src/components/ListView.js.map +1 -1
- package/dist/src/console/ConsoleCapture.d.ts +19 -0
- package/dist/src/console/ConsoleCapture.d.ts.map +1 -1
- package/dist/src/console/ConsoleCapture.js +132 -9
- package/dist/src/console/ConsoleCapture.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +5 -3
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +4 -2
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hosts/index.d.ts +1 -1
- package/dist/src/hosts/index.d.ts.map +1 -1
- package/dist/src/hosts/index.js +3 -2
- package/dist/src/hosts/index.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +5 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +10 -0
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/text.d.ts +48 -1
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +200 -5
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/remote/Procedures.d.ts +11 -0
- package/dist/src/remote/Procedures.d.ts.map +1 -1
- package/dist/src/remote/Procedures.js +17 -1
- package/dist/src/remote/Procedures.js.map +1 -1
- package/dist/src/remote/Router.d.ts +12 -1
- package/dist/src/remote/Router.d.ts.map +1 -1
- package/dist/src/remote/Router.js +1 -0
- package/dist/src/remote/Router.js.map +1 -1
- package/dist/src/remote/index.d.ts.map +1 -1
- package/dist/src/remote/index.js +14 -0
- package/dist/src/remote/index.js.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +9 -1
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/test/render-tui.js +2 -2
- package/dist/src/test/render-tui.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/jsx-runtime.ts +2 -1
- package/package.json +2 -2
- package/src/components/ListView.tsx +50 -13
- package/src/console/ConsoleCapture.ts +151 -9
- package/src/hooks/use-scroll.ts +9 -5
- package/src/hosts/index.ts +13 -2
- package/src/hosts/scroll.ts +13 -0
- package/src/hosts/text.ts +242 -5
- package/src/index.ts +1 -1
- package/src/remote/Procedures.ts +19 -1
- package/src/remote/Router.ts +14 -1
- package/src/remote/index.ts +15 -1
- package/src/renderer.ts +9 -1
- 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.
|
|
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.
|
|
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
|
-
|
|
128
|
+
// useLayoutEffect runs before paint - prevents visible "jump" when navigating
|
|
129
|
+
useLayoutEffect(() => {
|
|
100
130
|
if (selectedIndex !== prevSelectedRef.current) {
|
|
101
131
|
prevSelectedRef.current = selectedIndex
|
|
102
|
-
|
|
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 =
|
|
129
|
-
const bottomSpacerHeight =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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) {
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -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,
|
|
358
|
+
const currentMaxOffset = Math.max(0, effectiveContentSize - viewportSize)
|
|
355
359
|
setOffset(Math.min(currentMaxOffset, itemEnd - viewportSize + padding))
|
|
356
360
|
}
|
|
357
361
|
},
|
package/src/hosts/index.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/hosts/scroll.ts
CHANGED
|
@@ -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
|
}
|