@effect-tui/react 0.4.2 → 0.6.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/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 { TextProps } from "./src/hosts/text.js"
14
+ import type { 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
+ styledtext: StyledTextProps
43
44
  spacer: SpacerProps
44
45
  vstack: VStackProps & { children?: React.ReactNode; __static?: boolean }
45
46
  hstack: HStackProps & { children?: React.ReactNode }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.4.2",
3
+ "version": "0.6.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.4.2",
86
+ "@effect-tui/core": "^0.6.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -0,0 +1,112 @@
1
+ // ListView.tsx — Selection-aware scrolling list with scroll-into-view behavior
2
+ import type { ReactNode } from "react"
3
+ import { useEffect, useRef } from "react"
4
+ import { useScroll } from "../hooks/use-scroll.js"
5
+
6
+ export interface ListViewProps<T> {
7
+ /** Items to render */
8
+ items: readonly T[]
9
+ /** Currently selected index */
10
+ selectedIndex: number
11
+ /** Render function for each item */
12
+ renderItem: (item: T, index: number, isSelected: boolean) => ReactNode
13
+ /** Extract unique key for each item */
14
+ keyExtractor: (item: T, index: number) => string | number
15
+ /** Height of each item in rows (default: 1) */
16
+ itemHeight?: number
17
+ /** Padding rows to keep around selected item (default: 0) */
18
+ scrollPadding?: number
19
+ /** Show scrollbar (default: true) */
20
+ showScrollbar?: boolean
21
+ /** Empty state content when items is empty */
22
+ emptyContent?: ReactNode
23
+ }
24
+
25
+ /**
26
+ * A scrolling list view with selection and scroll-into-view behavior.
27
+ *
28
+ * Features:
29
+ * - Automatically scrolls to keep selected item visible when selection changes
30
+ * - Supports mouse wheel scrolling with macOS-style acceleration
31
+ * - Shows a scroll bar when content overflows
32
+ *
33
+ * Keyboard navigation should be handled by the parent component - update
34
+ * `selectedIndex` and ListView will scroll to keep it visible.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const [selectedIndex, setSelectedIndex] = useState(0)
39
+ *
40
+ * // Handle keyboard in parent
41
+ * useKeyboard((key) => {
42
+ * if (key.name === "down" || key.text === "j") {
43
+ * setSelectedIndex(i => Math.min(items.length - 1, i + 1))
44
+ * }
45
+ * if (key.name === "up" || key.text === "k") {
46
+ * setSelectedIndex(i => Math.max(0, i - 1))
47
+ * }
48
+ * })
49
+ *
50
+ * return (
51
+ * <ListView
52
+ * items={cards}
53
+ * selectedIndex={selectedIndex}
54
+ * renderItem={(card, i, isSelected) => (
55
+ * <CardListItem card={card} isSelected={isSelected} />
56
+ * )}
57
+ * keyExtractor={(card) => card.id}
58
+ * />
59
+ * )
60
+ * ```
61
+ */
62
+ export function ListView<T>({
63
+ items,
64
+ selectedIndex,
65
+ renderItem,
66
+ keyExtractor,
67
+ itemHeight = 1,
68
+ scrollPadding = 0,
69
+ showScrollbar = true,
70
+ emptyContent,
71
+ }: ListViewProps<T>) {
72
+ const { scrollProps, scrollToVisible } = useScroll({
73
+ enableKeyboard: false, // Parent handles keyboard for selection
74
+ enableMouseWheel: true, // Free scroll with wheel
75
+ })
76
+
77
+ // Track previous selection to detect changes
78
+ const prevSelectedRef = useRef(selectedIndex)
79
+
80
+ // Scroll to keep selection visible when it changes
81
+ useEffect(() => {
82
+ if (selectedIndex !== prevSelectedRef.current) {
83
+ prevSelectedRef.current = selectedIndex
84
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding)
85
+ }
86
+ }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible])
87
+
88
+ // Also scroll on initial render if selection is not 0
89
+ useEffect(() => {
90
+ if (selectedIndex > 0) {
91
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding)
92
+ }
93
+ // Only run on mount
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [])
96
+
97
+ if (items.length === 0 && emptyContent) {
98
+ return <>{emptyContent}</>
99
+ }
100
+
101
+ return (
102
+ <scroll {...scrollProps} showScrollbar={showScrollbar}>
103
+ <vstack>
104
+ {items.map((item, index) => (
105
+ <vstack key={keyExtractor(item, index)}>
106
+ {renderItem(item, index, index === selectedIndex)}
107
+ </vstack>
108
+ ))}
109
+ </vstack>
110
+ </scroll>
111
+ )
112
+ }
@@ -1,6 +1,7 @@
1
1
  import { Colors, type ColorValue } from "@effect-tui/core"
2
2
  import type { BundledLanguage, BundledTheme } from "shiki"
3
3
  import { CodeBlock } from "../codeblock.js"
4
+ import type { StyledSpan } from "../hosts/text.js"
4
5
 
5
6
  export interface MarkdownTheme {
6
7
  /** Header colors by level (h1, h2, h3+) */
@@ -54,6 +55,8 @@ export interface MarkdownProps {
54
55
  codeTheme?: BundledTheme
55
56
  /** Enable text wrapping (default: false, text is truncated) */
56
57
  wrap?: boolean
58
+ /** Max width for wrapping styled paragraphs. Required when wrap=true for proper inline formatting. */
59
+ maxWidth?: number
57
60
  }
58
61
 
59
62
  // Parsed markdown elements
@@ -226,42 +229,68 @@ function parseMarkdown(content: string): MdElement[] {
226
229
  }
227
230
 
228
231
  /**
229
- * Render inline spans as text elements.
230
- * Uses <text wrap> for host-level wrapping when wrap=true.
232
+ * Convert markdown spans to styled spans for StyledTextHost
231
233
  */
232
- function renderSpans(spans: MdSpan[], theme: Required<MarkdownTheme>, wrap = false) {
234
+ function toStyledSpans(spans: MdSpan[], theme: Required<MarkdownTheme>): StyledSpan[] {
235
+ const result: StyledSpan[] = []
236
+ for (const span of spans) {
237
+ switch (span.type) {
238
+ case "text":
239
+ result.push({ text: span.text, fg: theme.text })
240
+ break
241
+ case "bold":
242
+ result.push({ text: span.text, fg: theme.bold, bold: true })
243
+ break
244
+ case "italic":
245
+ result.push({ text: span.text, fg: theme.italic, italic: true })
246
+ break
247
+ case "code":
248
+ result.push({ text: span.text, fg: theme.code, bg: theme.codeBg })
249
+ break
250
+ case "link":
251
+ result.push({ text: span.text, fg: theme.link })
252
+ result.push({ text: ` (${span.url})`, fg: theme.linkUrl })
253
+ break
254
+ }
255
+ }
256
+ return result
257
+ }
258
+
259
+ /**
260
+ * Render inline spans as text elements (used for non-wrapping contexts like lists).
261
+ * For wrapping paragraphs, use <styledtext> instead.
262
+ */
263
+ function renderSpans(spans: MdSpan[], theme: Required<MarkdownTheme>) {
233
264
  return spans.map((span, i) => {
234
265
  switch (span.type) {
235
266
  case "text":
236
267
  return (
237
- <text key={i} fg={theme.text} wrap={wrap}>
268
+ <text key={i} fg={theme.text}>
238
269
  {span.text}
239
270
  </text>
240
271
  )
241
272
  case "bold":
242
273
  return (
243
- <text key={i} fg={theme.bold} bold wrap={wrap}>
274
+ <text key={i} fg={theme.bold} bold>
244
275
  {span.text}
245
276
  </text>
246
277
  )
247
278
  case "italic":
248
279
  return (
249
- <text key={i} fg={theme.italic} italic wrap={wrap}>
280
+ <text key={i} fg={theme.italic} italic>
250
281
  {span.text}
251
282
  </text>
252
283
  )
253
284
  case "code":
254
285
  return (
255
- <text key={i} fg={theme.code} bg={theme.codeBg} wrap={wrap}>
286
+ <text key={i} fg={theme.code} bg={theme.codeBg}>
256
287
  {span.text}
257
288
  </text>
258
289
  )
259
290
  case "link":
260
291
  return (
261
292
  <hstack key={i}>
262
- <text fg={theme.link} wrap={wrap}>
263
- {span.text}
264
- </text>
293
+ <text fg={theme.link}>{span.text}</text>
265
294
  <text fg={theme.linkUrl}>{" ("}</text>
266
295
  <text fg={theme.linkUrl}>{span.url}</text>
267
296
  <text fg={theme.linkUrl}>{")"}</text>
@@ -327,7 +356,11 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
327
356
  </text>
328
357
  )
329
358
  case "paragraph":
330
- return <hstack key={i}>{renderSpans(el.spans, theme, wrap)}</hstack>
359
+ // Use styledtext for proper wrapping of styled inline text
360
+ if (wrap) {
361
+ return <styledtext key={i} spans={toStyledSpans(el.spans, theme)} wrap />
362
+ }
363
+ return <hstack key={i}>{renderSpans(el.spans, theme)}</hstack>
331
364
  case "code":
332
365
  return (
333
366
  <CodeBlock
@@ -343,7 +376,11 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
343
376
  return (
344
377
  <hstack key={i}>
345
378
  <text fg={theme.quoteBorder}>{"│ "}</text>
346
- {renderSpans(el.spans, theme, wrap)}
379
+ {wrap ? (
380
+ <styledtext spans={toStyledSpans(el.spans, theme)} wrap />
381
+ ) : (
382
+ renderSpans(el.spans, theme)
383
+ )}
347
384
  </hstack>
348
385
  )
349
386
  case "ul":
@@ -352,7 +389,11 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
352
389
  {el.items.map((item, j) => (
353
390
  <hstack key={j}>
354
391
  <text fg={theme.listMarker}>{" • "}</text>
355
- {renderSpans(item, theme, wrap)}
392
+ {wrap ? (
393
+ <styledtext spans={toStyledSpans(item, theme)} wrap />
394
+ ) : (
395
+ renderSpans(item, theme)
396
+ )}
356
397
  </hstack>
357
398
  ))}
358
399
  </vstack>
@@ -363,7 +404,11 @@ export function Markdown({ content, theme: themeOverrides, codeTheme = "nord", w
363
404
  {el.items.map((item, j) => (
364
405
  <hstack key={j}>
365
406
  <text fg={theme.listMarker}>{` ${el.start + j}. `}</text>
366
- {renderSpans(item, theme, wrap)}
407
+ {wrap ? (
408
+ <styledtext spans={toStyledSpans(item, theme)} wrap />
409
+ ) : (
410
+ renderSpans(item, theme)
411
+ )}
367
412
  </hstack>
368
413
  ))}
369
414
  </vstack>
@@ -1,4 +1,5 @@
1
1
  export { Divider, type DividerProps } from "./Divider.js"
2
+ export { ListView, type ListViewProps } from "./ListView.js"
2
3
  export { Markdown, type MarkdownProps, type MarkdownTheme } from "./Markdown.js"
3
4
  export { MultilineTextInput, type MultilineTextInputProps } from "./MultilineTextInput.js"
4
5
  export { Overlay, type OverlayItemProps, type OverlayProps } from "./Overlay.js"
@@ -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, TextHost } from "./text.js"
11
+ import { RawTextHost, StyledTextHost, TextHost } from "./text.js"
12
12
  import { VStackHost } from "./vstack.js"
13
13
  import { ZStackHost } from "./zstack.js"
14
14
 
@@ -22,7 +22,7 @@ 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, TextHost, type TextProps } from "./text.js"
25
+ export { RawTextHost, StyledTextHost, TextHost, type StyledSpan, type StyledTextProps, type TextProps } from "./text.js"
26
26
  export { VStackHost, type VStackProps } from "./vstack.js"
27
27
  export { ZStackHost, type ZStackProps } from "./zstack.js"
28
28
 
@@ -30,6 +30,7 @@ export { ZStackHost, type ZStackProps } from "./zstack.js"
30
30
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
31
  export const hostRegistry: Record<string, new (props: any, ctx: HostContext) => BaseHost> = {
32
32
  text: TextHost,
33
+ styledtext: StyledTextHost,
33
34
  spacer: SpacerHost,
34
35
  vstack: VStackHost,
35
36
  hstack: HStackHost,
package/src/hosts/text.ts CHANGED
@@ -235,3 +235,172 @@ export class RawTextHost extends BaseHost {
235
235
  // Raw text has no props
236
236
  }
237
237
  }
238
+
239
+ // ============================================================================
240
+ // Styled Text Host - for inline formatted text that wraps as a unit
241
+ // ============================================================================
242
+
243
+ /** A span of text with optional styling */
244
+ export interface StyledSpan {
245
+ text: string
246
+ fg?: Color
247
+ bg?: Color
248
+ bold?: boolean
249
+ italic?: boolean
250
+ underline?: boolean
251
+ }
252
+
253
+ export interface StyledTextProps extends CommonProps {
254
+ /** Array of styled text spans */
255
+ spans: StyledSpan[]
256
+ /** Default text color */
257
+ fg?: Color
258
+ /** Default background color */
259
+ bg?: Color
260
+ /** If true, wrap text to multiple lines */
261
+ wrap?: boolean
262
+ }
263
+
264
+ /**
265
+ * Host for rendering multiple styled spans that wrap as a unit.
266
+ * Unlike using hstack with multiple text elements, this properly
267
+ * wraps styled inline text across lines.
268
+ */
269
+ export class StyledTextHost extends BaseHost {
270
+ spans: StyledSpan[] = []
271
+ fg?: Color
272
+ bg?: Color
273
+ wrap = false
274
+
275
+ // Cache wrapped lines - each line is an array of spans
276
+ private cachedLines: StyledSpan[][] | null = null
277
+ private cachedWidth = 0
278
+
279
+ constructor(props: StyledTextProps, ctx: HostContext) {
280
+ super("styledtext", props, ctx)
281
+ this.updateProps(props)
282
+ }
283
+
284
+ measure(maxW: number, maxH: number): Size {
285
+ if (this.wrap) {
286
+ this.cachedLines = this.wrapSpans(this.spans, maxW)
287
+ this.cachedWidth = maxW
288
+ const h = Math.min(this.cachedLines.length, maxH)
289
+ const w = this.cachedLines.reduce(
290
+ (max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
291
+ 0,
292
+ )
293
+ return { w, h }
294
+ }
295
+
296
+ // Non-wrap mode: single line, may truncate
297
+ const totalWidth = this.spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
298
+ return { w: Math.min(totalWidth, maxW), h: 1 }
299
+ }
300
+
301
+ /** Wrap spans into lines, breaking at word boundaries */
302
+ private wrapSpans(spans: StyledSpan[], maxWidth: number): StyledSpan[][] {
303
+ const lines: StyledSpan[][] = [[]]
304
+ let lineWidth = 0
305
+
306
+ for (const span of spans) {
307
+ // Split span text into words (keeping whitespace as separate tokens)
308
+ const tokens = span.text.split(/(\s+)/)
309
+
310
+ for (const token of tokens) {
311
+ if (!token) continue
312
+ const tokenWidth = displayWidth(token)
313
+ const isWhitespace = /^\s+$/.test(token)
314
+
315
+ if (lineWidth + tokenWidth <= maxWidth) {
316
+ // Token fits on current line
317
+ lines[lines.length - 1].push({ ...span, text: token })
318
+ lineWidth += tokenWidth
319
+ } else if (isWhitespace) {
320
+ // Skip whitespace at line break
321
+ continue
322
+ } else if (tokenWidth <= maxWidth) {
323
+ // Start new line with this token
324
+ lines.push([{ ...span, text: token }])
325
+ lineWidth = tokenWidth
326
+ } else {
327
+ // Token is longer than maxWidth - break by character
328
+ let charLine = ""
329
+ let charLineWidth = 0
330
+ for (const ch of token) {
331
+ const chWidth = displayWidth(ch)
332
+ if (lineWidth + charLineWidth + chWidth > maxWidth && (charLine || lineWidth > 0)) {
333
+ if (charLine) {
334
+ lines[lines.length - 1].push({ ...span, text: charLine })
335
+ }
336
+ lines.push([])
337
+ lineWidth = 0
338
+ charLine = ch
339
+ charLineWidth = chWidth
340
+ } else {
341
+ charLine += ch
342
+ charLineWidth += chWidth
343
+ }
344
+ }
345
+ if (charLine) {
346
+ lines[lines.length - 1].push({ ...span, text: charLine })
347
+ lineWidth += charLineWidth
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ // Remove empty lines at the end
354
+ while (lines.length > 0 && lines[lines.length - 1].length === 0) {
355
+ lines.pop()
356
+ }
357
+
358
+ return lines.length > 0 ? lines : [[]]
359
+ }
360
+
361
+ override layout(rect: Rect): void {
362
+ super.layout(rect)
363
+ }
364
+
365
+ render(buffer: CellBuffer, palette: Palette): void {
366
+ if (!this.rect) return
367
+
368
+ const inheritedBg = this.bg ?? getInheritedBg(this.parent)
369
+
370
+ // Get lines to render
371
+ const lines =
372
+ this.wrap && this.cachedLines && this.cachedWidth === this.rect.w
373
+ ? this.cachedLines
374
+ : this.wrap
375
+ ? this.wrapSpans(this.spans, this.rect.w)
376
+ : [this.spans]
377
+
378
+ for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
379
+ let x = this.rect.x
380
+ for (const span of lines[y]) {
381
+ const styleId = styleIdFromProps(palette, {
382
+ fg: span.fg ?? this.fg,
383
+ bg: span.bg ?? inheritedBg,
384
+ bold: span.bold,
385
+ italic: span.italic,
386
+ underline: span.underline,
387
+ })
388
+ const availableWidth = this.rect.w - (x - this.rect.x)
389
+ if (availableWidth <= 0) break
390
+ const textWidth = Math.min(displayWidth(span.text), availableWidth)
391
+ buffer.drawText(x, this.rect.y + y, span.text, styleId, textWidth)
392
+ x += displayWidth(span.text)
393
+ }
394
+ }
395
+ }
396
+
397
+ override updateProps(props: Record<string, unknown>): void {
398
+ super.updateProps(props)
399
+ this.spans = (props.spans as StyledSpan[] | undefined) ?? []
400
+ this.fg = props.fg as Color | undefined
401
+ this.bg = props.bg as Color | undefined
402
+ this.wrap = Boolean(props.wrap)
403
+ // Invalidate cache when props change
404
+ this.cachedLines = null
405
+ }
406
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export { CodeBlock } from "./codeblock.js"
6
6
  // Input components
7
7
  export {
8
8
  type ColumnProps,
9
+ ListView,
10
+ type ListViewProps,
9
11
  Markdown,
10
12
  type MarkdownProps,
11
13
  type MarkdownTheme,