@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.
Files changed (52) hide show
  1. package/dist/jsx-runtime.d.ts +4 -1
  2. package/dist/jsx-runtime.d.ts.map +1 -1
  3. package/dist/src/components/ListView.d.ts +9 -1
  4. package/dist/src/components/ListView.d.ts.map +1 -1
  5. package/dist/src/components/ListView.js +46 -11
  6. package/dist/src/components/ListView.js.map +1 -1
  7. package/dist/src/hooks/use-scroll.d.ts +9 -1
  8. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  9. package/dist/src/hooks/use-scroll.js +11 -5
  10. package/dist/src/hooks/use-scroll.js.map +1 -1
  11. package/dist/src/hosts/index.d.ts +1 -1
  12. package/dist/src/hosts/index.d.ts.map +1 -1
  13. package/dist/src/hosts/index.js +3 -2
  14. package/dist/src/hosts/index.js.map +1 -1
  15. package/dist/src/hosts/scroll.d.ts +5 -0
  16. package/dist/src/hosts/scroll.d.ts.map +1 -1
  17. package/dist/src/hosts/scroll.js +10 -0
  18. package/dist/src/hosts/scroll.js.map +1 -1
  19. package/dist/src/hosts/text.d.ts +48 -1
  20. package/dist/src/hosts/text.d.ts.map +1 -1
  21. package/dist/src/hosts/text.js +200 -5
  22. package/dist/src/hosts/text.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/remote/Procedures.d.ts +11 -0
  26. package/dist/src/remote/Procedures.d.ts.map +1 -1
  27. package/dist/src/remote/Procedures.js +17 -1
  28. package/dist/src/remote/Procedures.js.map +1 -1
  29. package/dist/src/remote/Router.d.ts +12 -1
  30. package/dist/src/remote/Router.d.ts.map +1 -1
  31. package/dist/src/remote/Router.js +1 -0
  32. package/dist/src/remote/Router.js.map +1 -1
  33. package/dist/src/remote/index.d.ts.map +1 -1
  34. package/dist/src/remote/index.js +14 -0
  35. package/dist/src/remote/index.js.map +1 -1
  36. package/dist/src/test/render-tui.d.ts +1 -1
  37. package/dist/src/test/render-tui.d.ts.map +1 -1
  38. package/dist/src/test/render-tui.js +20 -2
  39. package/dist/src/test/render-tui.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/jsx-runtime.ts +2 -1
  42. package/package.json +2 -2
  43. package/src/components/ListView.tsx +67 -13
  44. package/src/hooks/use-scroll.ts +21 -5
  45. package/src/hosts/index.ts +13 -2
  46. package/src/hosts/scroll.ts +13 -0
  47. package/src/hosts/text.ts +242 -5
  48. package/src/index.ts +1 -1
  49. package/src/remote/Procedures.ts +19 -1
  50. package/src/remote/Router.ts +14 -1
  51. package/src/remote/index.ts +15 -1
  52. 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.6.2",
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.6.2",
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
- useEffect(() => {
128
+ // useLayoutEffect runs before paint - prevents visible "jump" when navigating
129
+ useLayoutEffect(() => {
86
130
  if (selectedIndex !== prevSelectedRef.current) {
87
131
  prevSelectedRef.current = selectedIndex
88
- scrollToVisible(selectedIndex, itemHeight, scrollPadding)
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
- const totalHeight = items.length * itemHeight
107
- const startIndex = Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
108
- const endIndex = Math.min(items.length, Math.ceil((state.offset + state.viewportSize) / itemHeight) + overscan)
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
- {topSpacerHeight > 0 && <spacer minHeight={topSpacerHeight} />}
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
- {bottomSpacerHeight > 0 && <spacer minHeight={bottomSpacerHeight} />}
187
+ {/* Virtual bottom spacer - always render to maintain structure */}
188
+ <box key="bottom-spacer" height={bottomSpacerHeight} />
135
189
  </vstack>
136
190
  </scroll>
137
191
  )
@@ -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(axis === "vertical" ? termHeight : termWidth)
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, contentSize - viewportSize)
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,
@@ -8,7 +8,7 @@ import { OverlayHost } from "./overlay.js"
8
8
  import { OverlayItemHost } from "./overlay-item.js"
9
9
  import { ScrollHost } from "./scroll.js"
10
10
  import { SpacerHost } from "./spacer.js"
11
- import { RawTextHost, StyledTextHost, TextHost } from "./text.js"
11
+ import { RawTextHost, SpanHost, StyledTextHost, TextHost } from "./text.js"
12
12
  import { VStackHost } from "./vstack.js"
13
13
  import { ZStackHost } from "./zstack.js"
14
14
 
@@ -22,7 +22,17 @@ export { OverlayItemHost, type OverlayItemProps } from "./overlay-item.js"
22
22
  export { ScrollHost, type ScrollProps } from "./scroll.js"
23
23
  export { SingleChildHost } from "./single-child.js"
24
24
  export { SpacerHost, type SpacerProps } from "./spacer.js"
25
- export { RawTextHost, StyledTextHost, TextHost, type StyledSpan, type StyledTextProps, type TextProps } from "./text.js"
25
+ export {
26
+ RawTextHost,
27
+ SpanHost,
28
+ StyledTextHost,
29
+ TextHost,
30
+ type SpanProps,
31
+ type SpanStyle,
32
+ type StyledSpan,
33
+ type StyledTextProps,
34
+ type TextProps,
35
+ } from "./text.js"
26
36
  export { VStackHost, type VStackProps } from "./vstack.js"
27
37
  export { ZStackHost, type ZStackProps } from "./zstack.js"
28
38
 
@@ -30,6 +40,7 @@ export { ZStackHost, type ZStackProps } from "./zstack.js"
30
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
41
  export const hostRegistry: Record<string, new (props: any, ctx: HostContext) => BaseHost> = {
32
42
  text: TextHost,
43
+ span: SpanHost,
33
44
  styledtext: StyledTextHost,
34
45
  spacer: SpacerHost,
35
46
  vstack: VStackHost,
@@ -28,6 +28,8 @@ export interface ScrollProps extends CommonProps {
28
28
  onViewportSize?: (width: number, height: number) => void
29
29
  /** Called when effective offset changes (for syncing with useScroll when sticky adjusts) */
30
30
  onEffectiveOffset?: (offset: number) => void
31
+ /** Called when layout rect changes (for hit testing) */
32
+ onRect?: (x: number, y: number, w: number, h: number) => void
31
33
  }
32
34
 
33
35
  export class ScrollHost extends SingleChildHost {
@@ -44,6 +46,7 @@ export class ScrollHost extends SingleChildHost {
44
46
  onContentSize?: (width: number, height: number) => void
45
47
  onViewportSize?: (width: number, height: number) => void
46
48
  onEffectiveOffset?: (offset: number) => void
49
+ onRect?: (x: number, y: number, w: number, h: number) => void
47
50
 
48
51
  // Measured content dimensions (full size before clipping)
49
52
  private contentWidth = 0
@@ -53,6 +56,8 @@ export class ScrollHost extends SingleChildHost {
53
56
  private lastViewportH = 0
54
57
  private lastReportedContentW = 0
55
58
  private lastReportedContentH = 0
59
+ private lastRectX = -1
60
+ private lastRectY = -1
56
61
  // Track if we were at end (for sticky behavior)
57
62
  private wasAtEnd = true
58
63
  // Effective offset after sticky adjustment (used for rendering)
@@ -113,6 +118,13 @@ export class ScrollHost extends SingleChildHost {
113
118
  this.onViewportSize?.(rect.w, rect.h)
114
119
  }
115
120
 
121
+ // Report rect if position changed (for hit testing)
122
+ if (rect.x !== this.lastRectX || rect.y !== this.lastRectY) {
123
+ this.lastRectX = rect.x
124
+ this.lastRectY = rect.y
125
+ this.onRect?.(rect.x, rect.y, rect.w, rect.h)
126
+ }
127
+
116
128
  const child = this.child
117
129
  if (!child) return
118
130
 
@@ -260,5 +272,6 @@ export class ScrollHost extends SingleChildHost {
260
272
  this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
261
273
  this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
262
274
  this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
275
+ this.onRect = props.onRect as ScrollProps["onRect"]
263
276
  }
264
277
  }
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
- /** Get text content from RawTextHost children (cached between measure and render) */
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
- this.cachedContent = this.children
43
- .filter((c): c is RawTextHost => c instanceof RawTextHost)
44
- .map((c) => c.content)
45
- .join("")
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"
@@ -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) {}