@effect-tui/react 0.6.2 → 0.7.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 +9 -1
- package/dist/src/components/ListView.d.ts.map +1 -1
- package/dist/src/components/ListView.js +46 -11
- package/dist/src/components/ListView.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +9 -1
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +11 -5
- 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/test/render-tui.d.ts +1 -1
- package/dist/src/test/render-tui.d.ts.map +1 -1
- package/dist/src/test/render-tui.js +20 -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 +67 -13
- package/src/hooks/use-scroll.ts +21 -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/test/render-tui.ts +21 -3
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.7.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.7.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,14 @@ 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
|
|
28
|
+
/** @internal */
|
|
29
|
+
__debugViewportMeasured?: boolean
|
|
30
|
+
/** @internal */
|
|
31
|
+
__debugViewportSize?: number
|
|
32
|
+
/** @internal */
|
|
33
|
+
__debugContentSize?: number
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
/**
|
|
@@ -72,27 +81,63 @@ export function ListView<T>({
|
|
|
72
81
|
showScrollbar = true,
|
|
73
82
|
emptyContent,
|
|
74
83
|
overscan = 3,
|
|
84
|
+
onItemClick,
|
|
85
|
+
__debugViewportMeasured,
|
|
86
|
+
__debugViewportSize,
|
|
87
|
+
__debugContentSize,
|
|
75
88
|
}: ListViewProps<T>) {
|
|
89
|
+
const totalHeight = items.length * itemHeight
|
|
76
90
|
const { state, scrollProps, scrollToVisible } = useScroll({
|
|
77
91
|
enableKeyboard: false, // Parent handles keyboard for selection
|
|
78
92
|
enableMouseWheel: true, // Free scroll with wheel
|
|
93
|
+
initialViewportSize: __debugViewportSize,
|
|
94
|
+
initialContentSize: __debugContentSize,
|
|
79
95
|
})
|
|
96
|
+
const viewportMeasured = __debugViewportMeasured ?? state.viewportMeasured
|
|
97
|
+
const viewportSize = __debugViewportSize ?? state.viewportSize
|
|
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
|
+
)
|
|
80
123
|
|
|
81
124
|
// Track previous selection to detect changes
|
|
82
125
|
const prevSelectedRef = useRef(selectedIndex)
|
|
83
126
|
|
|
84
127
|
// Scroll to keep selection visible when it changes
|
|
85
|
-
|
|
128
|
+
// useLayoutEffect runs before paint - prevents visible "jump" when navigating
|
|
129
|
+
useLayoutEffect(() => {
|
|
86
130
|
if (selectedIndex !== prevSelectedRef.current) {
|
|
87
131
|
prevSelectedRef.current = selectedIndex
|
|
88
|
-
|
|
132
|
+
// Pass totalHeight to avoid stale contentSize issues when jumping to end
|
|
133
|
+
scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
|
|
89
134
|
}
|
|
90
|
-
}, [selectedIndex, itemHeight, scrollPadding, scrollToVisible])
|
|
135
|
+
}, [selectedIndex, itemHeight, scrollPadding, scrollToVisible, totalHeight])
|
|
91
136
|
|
|
92
137
|
// Also scroll on initial render if selection is not 0
|
|
93
138
|
useEffect(() => {
|
|
94
139
|
if (selectedIndex > 0) {
|
|
95
|
-
scrollToVisible(selectedIndex, itemHeight, scrollPadding)
|
|
140
|
+
scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
|
|
96
141
|
}
|
|
97
142
|
// Only run on mount
|
|
98
143
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -103,9 +148,13 @@ export function ListView<T>({
|
|
|
103
148
|
}
|
|
104
149
|
|
|
105
150
|
// Calculate visible range for virtualization
|
|
106
|
-
|
|
107
|
-
const startIndex =
|
|
108
|
-
|
|
151
|
+
// Before viewport is measured, render a reasonable default (not all items!)
|
|
152
|
+
const startIndex = viewportMeasured
|
|
153
|
+
? Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
|
|
154
|
+
: 0
|
|
155
|
+
const endIndex = viewportMeasured
|
|
156
|
+
? Math.min(items.length, Math.ceil((state.offset + viewportSize) / itemHeight) + overscan)
|
|
157
|
+
: Math.min(items.length, 50)
|
|
109
158
|
|
|
110
159
|
// Calculate spacer heights for virtual scrolling
|
|
111
160
|
const topSpacerHeight = startIndex * itemHeight
|
|
@@ -114,11 +163,16 @@ export function ListView<T>({
|
|
|
114
163
|
// Get visible slice of items
|
|
115
164
|
const visibleItems = items.slice(startIndex, endIndex)
|
|
116
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
|
+
|
|
117
171
|
return (
|
|
118
|
-
<scroll {...scrollProps} showScrollbar={showScrollbar}>
|
|
172
|
+
<scroll {...scrollProps} showScrollbar={showScrollbar} onRect={handleRect}>
|
|
119
173
|
<vstack>
|
|
120
|
-
{/* Virtual top spacer */}
|
|
121
|
-
|
|
174
|
+
{/* Virtual top spacer - always render to maintain structure */}
|
|
175
|
+
<box key="top-spacer" height={topSpacerHeight} />
|
|
122
176
|
|
|
123
177
|
{/* Render only visible items */}
|
|
124
178
|
{visibleItems.map((item, i) => {
|
|
@@ -130,8 +184,8 @@ export function ListView<T>({
|
|
|
130
184
|
)
|
|
131
185
|
})}
|
|
132
186
|
|
|
133
|
-
{/* Virtual bottom spacer */}
|
|
134
|
-
|
|
187
|
+
{/* Virtual bottom spacer - always render to maintain structure */}
|
|
188
|
+
<box key="bottom-spacer" height={bottomSpacerHeight} />
|
|
135
189
|
</vstack>
|
|
136
190
|
</scroll>
|
|
137
191
|
)
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface ScrollState {
|
|
|
91
91
|
maxOffset: number
|
|
92
92
|
/** Viewport height (or width for horizontal) */
|
|
93
93
|
viewportSize: number
|
|
94
|
+
/** Whether viewport size has been measured by the host */
|
|
95
|
+
viewportMeasured: boolean
|
|
94
96
|
/** Total content size */
|
|
95
97
|
contentSize: number
|
|
96
98
|
/** Whether we're at the start edge */
|
|
@@ -102,6 +104,10 @@ export interface ScrollState {
|
|
|
102
104
|
export interface UseScrollOptions {
|
|
103
105
|
/** Scroll axis: "vertical" (default) or "horizontal" */
|
|
104
106
|
axis?: "vertical" | "horizontal"
|
|
107
|
+
/** @internal Initial viewport size override (useful for tests) */
|
|
108
|
+
initialViewportSize?: number
|
|
109
|
+
/** @internal Initial content size override (useful for tests) */
|
|
110
|
+
initialContentSize?: number
|
|
105
111
|
/** Initial scroll offset */
|
|
106
112
|
initialOffset?: number
|
|
107
113
|
/** Enable keyboard navigation (default: true) */
|
|
@@ -135,14 +141,16 @@ export interface UseScrollReturn {
|
|
|
135
141
|
* @param position - The position (row/col index or pixel offset)
|
|
136
142
|
* @param itemSize - Size of each item (default: 1 for row-based lists)
|
|
137
143
|
* @param padding - Extra padding around the item (default: 0)
|
|
144
|
+
* @param totalSize - Optional known total content size (avoids stale state issues)
|
|
138
145
|
*/
|
|
139
|
-
scrollToVisible: (position: number, itemSize?: number, padding?: number) => void
|
|
146
|
+
scrollToVisible: (position: number, itemSize?: number, padding?: number, totalSize?: number) => void
|
|
140
147
|
/** Props to spread on <scroll> element */
|
|
141
148
|
scrollProps: {
|
|
142
149
|
offset: number
|
|
143
150
|
axis: "vertical" | "horizontal"
|
|
144
151
|
onContentSize: (width: number, height: number) => void
|
|
145
152
|
onViewportSize: (width: number, height: number) => void
|
|
153
|
+
onRect?: (x: number, y: number, w: number, h: number) => void
|
|
146
154
|
}
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -167,6 +175,8 @@ export interface UseScrollReturn {
|
|
|
167
175
|
export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
168
176
|
const {
|
|
169
177
|
axis = "vertical",
|
|
178
|
+
initialViewportSize,
|
|
179
|
+
initialContentSize,
|
|
170
180
|
initialOffset = 0,
|
|
171
181
|
enableKeyboard = true,
|
|
172
182
|
enableMouseWheel = true,
|
|
@@ -177,12 +187,14 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
177
187
|
} = options
|
|
178
188
|
|
|
179
189
|
const { width: termWidth, height: termHeight } = useTerminalSize()
|
|
190
|
+
const baseViewportSize = initialViewportSize ?? (axis === "vertical" ? termHeight : termWidth)
|
|
180
191
|
|
|
181
192
|
// Scroll state
|
|
182
193
|
const [offset, setOffsetRaw] = useState(initialOffset)
|
|
183
|
-
const [contentSize, setContentSize] = useState(0)
|
|
194
|
+
const [contentSize, setContentSize] = useState(initialContentSize ?? 0)
|
|
184
195
|
// Use terminal size as initial estimate, but scroll component will report actual size
|
|
185
|
-
const [viewportSize, setViewportSize] = useState(
|
|
196
|
+
const [viewportSize, setViewportSize] = useState(baseViewportSize)
|
|
197
|
+
const [viewportMeasured, setViewportMeasured] = useState(false)
|
|
186
198
|
|
|
187
199
|
// Refs for sticky scroll behavior
|
|
188
200
|
const wasAtEndRef = useRef(sticky)
|
|
@@ -267,6 +279,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
267
279
|
(width: number, height: number) => {
|
|
268
280
|
const newSize = axis === "vertical" ? height : width
|
|
269
281
|
setViewportSize(newSize)
|
|
282
|
+
setViewportMeasured(true)
|
|
270
283
|
},
|
|
271
284
|
[axis],
|
|
272
285
|
)
|
|
@@ -329,10 +342,12 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
329
342
|
// Scroll to make a position visible (for keeping selection in view)
|
|
330
343
|
// Uses refs to avoid changing on every scroll - only triggers when selection changes
|
|
331
344
|
const scrollToVisible = useCallback(
|
|
332
|
-
(position: number, itemSize = 1, padding = 0) => {
|
|
345
|
+
(position: number, itemSize = 1, padding = 0, totalSize?: number) => {
|
|
333
346
|
const currentOffset = offsetRef.current
|
|
334
347
|
const itemStart = position * itemSize
|
|
335
348
|
const itemEnd = itemStart + itemSize
|
|
349
|
+
// Use provided totalSize if available (more accurate than potentially stale contentSize)
|
|
350
|
+
const effectiveContentSize = totalSize ?? contentSize
|
|
336
351
|
|
|
337
352
|
// If item is above viewport, scroll up to show it
|
|
338
353
|
if (itemStart < currentOffset + padding) {
|
|
@@ -340,7 +355,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
340
355
|
}
|
|
341
356
|
// If item is below viewport, scroll down to show it
|
|
342
357
|
else if (itemEnd > currentOffset + viewportSize - padding) {
|
|
343
|
-
const currentMaxOffset = Math.max(0,
|
|
358
|
+
const currentMaxOffset = Math.max(0, effectiveContentSize - viewportSize)
|
|
344
359
|
setOffset(Math.min(currentMaxOffset, itemEnd - viewportSize + padding))
|
|
345
360
|
}
|
|
346
361
|
},
|
|
@@ -351,6 +366,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
351
366
|
offset,
|
|
352
367
|
maxOffset,
|
|
353
368
|
viewportSize,
|
|
369
|
+
viewportMeasured,
|
|
354
370
|
contentSize,
|
|
355
371
|
atStart,
|
|
356
372
|
atEnd,
|
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
|
}
|
package/src/hosts/text.ts
CHANGED
|
@@ -28,33 +28,164 @@ export class TextHost extends BaseHost {
|
|
|
28
28
|
private cachedWidth = 0
|
|
29
29
|
// Cache content to avoid rescanning children each frame
|
|
30
30
|
private cachedContent: string | null = null
|
|
31
|
+
// Cache for styled mode
|
|
32
|
+
private cachedStyledLines: StyledSpan[][] | null = null
|
|
33
|
+
private hasSpans = false
|
|
31
34
|
|
|
32
35
|
constructor(props: TextProps, ctx: HostContext) {
|
|
33
36
|
super("text", props, ctx)
|
|
34
37
|
this.updateProps(props)
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/**
|
|
40
|
+
/** Check if we have SpanHost children (requires styled rendering) */
|
|
41
|
+
private checkForSpans(): boolean {
|
|
42
|
+
return this.children.some((c) => c instanceof SpanHost)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get text content from all children including SpanHost (cached between measure and render) */
|
|
38
46
|
private getContent(): string {
|
|
39
47
|
if (this.cachedContent !== null) {
|
|
40
48
|
return this.cachedContent
|
|
41
49
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
for (const child of this.children) {
|
|
52
|
+
if (child instanceof RawTextHost) {
|
|
53
|
+
parts.push(child.content)
|
|
54
|
+
} else if (child instanceof SpanHost) {
|
|
55
|
+
parts.push(child.getContent())
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
this.cachedContent = parts.join("")
|
|
46
59
|
return this.cachedContent
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/** Collect children as styled spans for multi-style rendering */
|
|
63
|
+
private collectSpans(): StyledSpan[] {
|
|
64
|
+
const spans: StyledSpan[] = []
|
|
65
|
+
|
|
66
|
+
for (const child of this.children) {
|
|
67
|
+
if (child instanceof RawTextHost) {
|
|
68
|
+
if (child.content) {
|
|
69
|
+
spans.push({
|
|
70
|
+
text: child.content,
|
|
71
|
+
// Inherit TextHost's styles
|
|
72
|
+
fg: this.fg,
|
|
73
|
+
bg: this.bg,
|
|
74
|
+
bold: this.bold,
|
|
75
|
+
italic: this.italic,
|
|
76
|
+
underline: this.underline,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} else if (child instanceof SpanHost) {
|
|
80
|
+
const content = child.getContent()
|
|
81
|
+
if (content) {
|
|
82
|
+
spans.push({
|
|
83
|
+
text: content,
|
|
84
|
+
// Span's styles, falling back to TextHost's
|
|
85
|
+
fg: child.fg ?? this.fg,
|
|
86
|
+
bg: child.bg ?? this.bg,
|
|
87
|
+
bold: child.bold || this.bold,
|
|
88
|
+
italic: child.italic || this.italic,
|
|
89
|
+
underline: child.underline || this.underline,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return spans
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Wrap spans into lines, breaking at word boundaries (same logic as StyledTextHost) */
|
|
99
|
+
private wrapSpans(spans: StyledSpan[], maxWidth: number): StyledSpan[][] {
|
|
100
|
+
const lines: StyledSpan[][] = [[]]
|
|
101
|
+
let lineWidth = 0
|
|
102
|
+
|
|
103
|
+
for (const span of spans) {
|
|
104
|
+
// Split span text into words (keeping whitespace as separate tokens)
|
|
105
|
+
const tokens = span.text.split(/(\s+)/)
|
|
106
|
+
|
|
107
|
+
for (const token of tokens) {
|
|
108
|
+
if (!token) continue
|
|
109
|
+
const tokenWidth = displayWidth(token)
|
|
110
|
+
const isWhitespace = /^\s+$/.test(token)
|
|
111
|
+
|
|
112
|
+
if (lineWidth + tokenWidth <= maxWidth) {
|
|
113
|
+
// Token fits on current line
|
|
114
|
+
lines[lines.length - 1].push({ ...span, text: token })
|
|
115
|
+
lineWidth += tokenWidth
|
|
116
|
+
} else if (isWhitespace) {
|
|
117
|
+
// Skip whitespace at line break
|
|
118
|
+
continue
|
|
119
|
+
} else if (tokenWidth <= maxWidth) {
|
|
120
|
+
// Start new line with this token
|
|
121
|
+
lines.push([{ ...span, text: token }])
|
|
122
|
+
lineWidth = tokenWidth
|
|
123
|
+
} else {
|
|
124
|
+
// Token is longer than maxWidth - break by character
|
|
125
|
+
let charLine = ""
|
|
126
|
+
let charLineWidth = 0
|
|
127
|
+
for (const ch of token) {
|
|
128
|
+
const chWidth = displayWidth(ch)
|
|
129
|
+
if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
|
|
130
|
+
if (charLine) {
|
|
131
|
+
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
132
|
+
}
|
|
133
|
+
lines.push([])
|
|
134
|
+
lineWidth = 0
|
|
135
|
+
charLine = ch
|
|
136
|
+
charLineWidth = chWidth
|
|
137
|
+
} else {
|
|
138
|
+
charLine += ch
|
|
139
|
+
charLineWidth += chWidth
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (charLine) {
|
|
143
|
+
lines[lines.length - 1].push({ ...span, text: charLine })
|
|
144
|
+
lineWidth += charLineWidth
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Remove empty lines at the end
|
|
151
|
+
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
|
|
152
|
+
lines.pop()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return lines.length > 0 ? lines : [[]]
|
|
156
|
+
}
|
|
157
|
+
|
|
49
158
|
/** Invalidate content cache when children change */
|
|
50
159
|
private invalidateContent(): void {
|
|
51
160
|
this.cachedContent = null
|
|
52
161
|
this.cachedLines = null
|
|
162
|
+
this.cachedStyledLines = null
|
|
53
163
|
}
|
|
54
164
|
|
|
55
165
|
measure(maxW: number, maxH: number): Size {
|
|
56
166
|
// Invalidate content cache at start of measure (will be recomputed on demand)
|
|
57
167
|
this.invalidateContent()
|
|
168
|
+
this.hasSpans = this.checkForSpans()
|
|
169
|
+
|
|
170
|
+
// Styled mode: use span-aware rendering
|
|
171
|
+
if (this.hasSpans) {
|
|
172
|
+
const spans = this.collectSpans()
|
|
173
|
+
if (this.wrap) {
|
|
174
|
+
this.cachedStyledLines = this.wrapSpans(spans, maxW)
|
|
175
|
+
this.cachedWidth = maxW
|
|
176
|
+
const h = Math.min(this.cachedStyledLines.length, maxH)
|
|
177
|
+
const w = this.cachedStyledLines.reduce(
|
|
178
|
+
(max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
|
|
179
|
+
0,
|
|
180
|
+
)
|
|
181
|
+
return { w, h }
|
|
182
|
+
}
|
|
183
|
+
// Non-wrap styled mode
|
|
184
|
+
const totalWidth = spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
|
|
185
|
+
return { w: Math.min(totalWidth, maxW), h: 1 }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Simple mode: single style for all content
|
|
58
189
|
const content = this.getContent()
|
|
59
190
|
const rawLines = content.split("\n")
|
|
60
191
|
|
|
@@ -145,6 +276,37 @@ export class TextHost extends BaseHost {
|
|
|
145
276
|
const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
|
|
146
277
|
const inheritedBg = this.bg ?? getInheritedBg(this.parent)
|
|
147
278
|
|
|
279
|
+
// Styled mode: render with per-span styles
|
|
280
|
+
if (this.hasSpans) {
|
|
281
|
+
const spans = this.collectSpans()
|
|
282
|
+
const lines =
|
|
283
|
+
this.wrap && this.cachedStyledLines && this.cachedWidth === this.rect.w
|
|
284
|
+
? this.cachedStyledLines
|
|
285
|
+
: this.wrap
|
|
286
|
+
? this.wrapSpans(spans, this.rect.w)
|
|
287
|
+
: [spans]
|
|
288
|
+
|
|
289
|
+
for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
|
|
290
|
+
let x = this.rect.x
|
|
291
|
+
for (const span of lines[y]) {
|
|
292
|
+
const spanStyleId = styleIdFromProps(palette, {
|
|
293
|
+
fg: span.fg ?? this.fg,
|
|
294
|
+
bg: span.bg ?? inheritedBg,
|
|
295
|
+
bold: span.bold,
|
|
296
|
+
italic: span.italic,
|
|
297
|
+
underline: span.underline,
|
|
298
|
+
})
|
|
299
|
+
const availableWidth = this.rect.w - (x - this.rect.x)
|
|
300
|
+
if (availableWidth <= 0) break
|
|
301
|
+
const textWidth = Math.min(displayWidth(span.text), availableWidth)
|
|
302
|
+
buffer.drawText(x, this.rect.y + y, span.text, spanStyleId, textWidth)
|
|
303
|
+
x += displayWidth(span.text)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Simple mode: single style for all content
|
|
148
310
|
const styleId = styleIdFromProps(palette, {
|
|
149
311
|
fg: this.fg,
|
|
150
312
|
bg: inheritedBg,
|
|
@@ -236,6 +398,81 @@ export class RawTextHost extends BaseHost {
|
|
|
236
398
|
}
|
|
237
399
|
}
|
|
238
400
|
|
|
401
|
+
// ============================================================================
|
|
402
|
+
// Span Host - inline styled text within a TextHost
|
|
403
|
+
// ============================================================================
|
|
404
|
+
|
|
405
|
+
/** Reusable style object for spans */
|
|
406
|
+
export interface SpanStyle {
|
|
407
|
+
fg?: Color
|
|
408
|
+
bg?: Color
|
|
409
|
+
bold?: boolean
|
|
410
|
+
italic?: boolean
|
|
411
|
+
underline?: boolean
|
|
412
|
+
inverse?: boolean
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface SpanProps extends CommonProps {
|
|
416
|
+
fg?: Color
|
|
417
|
+
bg?: Color
|
|
418
|
+
bold?: boolean
|
|
419
|
+
italic?: boolean
|
|
420
|
+
underline?: boolean
|
|
421
|
+
inverse?: boolean
|
|
422
|
+
/** Reusable style object. Individual props override textStyle values. */
|
|
423
|
+
textStyle?: SpanStyle
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Host for inline styled spans within a TextHost.
|
|
428
|
+
* Usage: <text>Hello <span fg={GREEN}>World</span></text>
|
|
429
|
+
*
|
|
430
|
+
* Does not render independently - parent TextHost handles rendering.
|
|
431
|
+
*/
|
|
432
|
+
export class SpanHost extends BaseHost {
|
|
433
|
+
fg?: Color
|
|
434
|
+
bg?: Color
|
|
435
|
+
bold = false
|
|
436
|
+
italic = false
|
|
437
|
+
underline = false
|
|
438
|
+
inverse = false
|
|
439
|
+
|
|
440
|
+
constructor(props: SpanProps, ctx: HostContext) {
|
|
441
|
+
super("span", props, ctx)
|
|
442
|
+
this.updateProps(props)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Get text content from RawTextHost children */
|
|
446
|
+
getContent(): string {
|
|
447
|
+
return this.children
|
|
448
|
+
.filter((c): c is RawTextHost => c instanceof RawTextHost)
|
|
449
|
+
.map((c) => c.content)
|
|
450
|
+
.join("")
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
measure(_maxW: number, _maxH: number): Size {
|
|
454
|
+
// Span doesn't measure independently - parent TextHost handles layout
|
|
455
|
+
return { w: 0, h: 0 }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
render(_buffer: CellBuffer, _palette: Palette): void {
|
|
459
|
+
// Span doesn't render independently - parent TextHost handles rendering
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
override updateProps(props: Record<string, unknown>): void {
|
|
463
|
+
super.updateProps(props)
|
|
464
|
+
const textStyle = props.textStyle as SpanStyle | undefined
|
|
465
|
+
|
|
466
|
+
// Individual props override textStyle object
|
|
467
|
+
this.fg = props.fg !== undefined ? (props.fg as Color) : textStyle?.fg
|
|
468
|
+
this.bg = props.bg !== undefined ? (props.bg as Color) : textStyle?.bg
|
|
469
|
+
this.bold = props.bold !== undefined ? Boolean(props.bold) : Boolean(textStyle?.bold)
|
|
470
|
+
this.italic = props.italic !== undefined ? Boolean(props.italic) : Boolean(textStyle?.italic)
|
|
471
|
+
this.underline = props.underline !== undefined ? Boolean(props.underline) : Boolean(textStyle?.underline)
|
|
472
|
+
this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
239
476
|
// ============================================================================
|
|
240
477
|
// Styled Text Host - for inline formatted text that wraps as a unit
|
|
241
478
|
// ============================================================================
|
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
|
|
|
59
59
|
export type { HStackProps } from "./hosts/hstack.js"
|
|
60
60
|
export type { ScrollProps } from "./hosts/scroll.js"
|
|
61
61
|
export type { SpacerProps } from "./hosts/spacer.js"
|
|
62
|
-
export type { TextProps } from "./hosts/text.js"
|
|
62
|
+
export type { SpanProps, SpanStyle, TextProps } from "./hosts/text.js"
|
|
63
63
|
export type { VStackProps } from "./hosts/vstack.js"
|
|
64
64
|
export type { ZStackProps } from "./hosts/zstack.js"
|
|
65
65
|
export type { ColorInput, MotionValue, RGBA, SpringOptions } from "./motion/index.js"
|
package/src/remote/Procedures.ts
CHANGED
|
@@ -46,5 +46,23 @@ const Info = Rpc.make("Info", {
|
|
|
46
46
|
}),
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
+
// Log entry schema
|
|
50
|
+
const LogEntrySchema = Schema.Struct({
|
|
51
|
+
timestamp: Schema.String,
|
|
52
|
+
level: Schema.String,
|
|
53
|
+
message: Schema.String,
|
|
54
|
+
file: Schema.optional(Schema.String),
|
|
55
|
+
line: Schema.optional(Schema.Number),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// GetLogs - get accumulated console logs
|
|
59
|
+
const GetLogs = Rpc.make("GetLogs", {
|
|
60
|
+
payload: { limit: Schema.optional(Schema.Number) },
|
|
61
|
+
success: Schema.Struct({
|
|
62
|
+
entries: Schema.Array(LogEntrySchema),
|
|
63
|
+
total: Schema.Number,
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
49
67
|
// Group all RPCs together
|
|
50
|
-
export class TuiRpcs extends RpcGroup.make(Screenshot, SendKey, Paste, Resize, Info) {}
|
|
68
|
+
export class TuiRpcs extends RpcGroup.make(Screenshot, SendKey, Paste, Resize, Info, GetLogs) {}
|