@effect-tui/react 0.4.2 → 0.5.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 +2 -1
- package/dist/jsx-runtime.d.ts.map +1 -1
- package/dist/src/components/Markdown.d.ts +2 -0
- package/dist/src/components/Markdown.d.ts.map +1 -1
- package/dist/src/components/Markdown.js +43 -12
- package/dist/src/components/Markdown.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/text.d.ts +39 -0
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +130 -0
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/jsx-runtime.ts +2 -1
- package/package.json +2 -2
- package/src/components/Markdown.tsx +59 -14
- package/src/hosts/index.ts +3 -2
- package/src/hosts/text.ts +169 -0
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.
|
|
3
|
+
"version": "0.5.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.5.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
|
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
|
-
*
|
|
230
|
-
* Uses <text wrap> for host-level wrapping when wrap=true.
|
|
232
|
+
* Convert markdown spans to styled spans for StyledTextHost
|
|
231
233
|
*/
|
|
232
|
-
function
|
|
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}
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
407
|
+
{wrap ? (
|
|
408
|
+
<styledtext spans={toStyledSpans(item, theme)} wrap />
|
|
409
|
+
) : (
|
|
410
|
+
renderSpans(item, theme)
|
|
411
|
+
)}
|
|
367
412
|
</hstack>
|
|
368
413
|
))}
|
|
369
414
|
</vstack>
|
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, 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
|
+
}
|