@effect-tui/react 0.6.3 → 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 +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/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/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/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/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.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,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
|
)
|
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
|
}
|
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) {}
|
package/src/remote/Router.ts
CHANGED
|
@@ -4,6 +4,15 @@ import type { KeyMsg } from "@effect-tui/core"
|
|
|
4
4
|
import { Context, Effect, type Layer } from "effect"
|
|
5
5
|
import { TuiRpcs } from "./Procedures.js"
|
|
6
6
|
|
|
7
|
+
// Log entry result type
|
|
8
|
+
export interface LogEntryResult {
|
|
9
|
+
timestamp: string
|
|
10
|
+
level: string
|
|
11
|
+
message: string
|
|
12
|
+
file?: string
|
|
13
|
+
line?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
// Service interface for the TUI session
|
|
8
17
|
export interface TuiSessionImpl {
|
|
9
18
|
readonly getScreenshot: () => string
|
|
@@ -17,6 +26,7 @@ export interface TuiSessionImpl {
|
|
|
17
26
|
entryPath?: string
|
|
18
27
|
name?: string
|
|
19
28
|
}
|
|
29
|
+
readonly getLogEntries: (limit?: number) => { entries: LogEntryResult[]; total: number }
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
export class TuiSession extends Context.Tag("TuiSession")<TuiSession, TuiSessionImpl>() {}
|
|
@@ -27,7 +37,8 @@ export const HandlersLive: Layer.Layer<
|
|
|
27
37
|
| Rpc.Handler<"SendKey">
|
|
28
38
|
| Rpc.Handler<"Paste">
|
|
29
39
|
| Rpc.Handler<"Resize">
|
|
30
|
-
| Rpc.Handler<"Info"
|
|
40
|
+
| Rpc.Handler<"Info">
|
|
41
|
+
| Rpc.Handler<"GetLogs">,
|
|
31
42
|
never,
|
|
32
43
|
TuiSession
|
|
33
44
|
> = TuiRpcs.toLayer(
|
|
@@ -55,6 +66,8 @@ export const HandlersLive: Layer.Layer<
|
|
|
55
66
|
Resize: ({ width, height }) => Effect.sync(() => session.dispatchResize(width, height)),
|
|
56
67
|
|
|
57
68
|
Info: () => Effect.sync(() => session.getInfo()),
|
|
69
|
+
|
|
70
|
+
GetLogs: ({ limit }) => Effect.sync(() => session.getLogEntries(limit)),
|
|
58
71
|
}
|
|
59
72
|
}),
|
|
60
73
|
)
|