@effect-tui/react 0.14.3 → 0.15.1
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/src/hosts/box.d.ts +6 -0
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +42 -11
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +48 -33
- package/dist/src/renderer.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/hosts/box.ts +47 -10
- package/src/renderer.ts +55 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
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.15.1",
|
|
87
87
|
"@effect/platform": "^0.94.1",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
package/src/hosts/box.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type Padding,
|
|
11
11
|
type PaddingInput,
|
|
12
12
|
resolvePadding,
|
|
13
|
+
tableBorderChars,
|
|
13
14
|
toColorValue,
|
|
14
15
|
} from "../utils/index.js"
|
|
15
16
|
import { SingleChildHost } from "./single-child.js"
|
|
@@ -28,6 +29,10 @@ export interface BoxProps extends CommonProps {
|
|
|
28
29
|
title?: string
|
|
29
30
|
/** Title text color (defaults to borderColor) */
|
|
30
31
|
titleColor?: ColorProp
|
|
32
|
+
/** Make title bold */
|
|
33
|
+
titleBold?: boolean
|
|
34
|
+
/** Show a divider line below the title (├──────┤) */
|
|
35
|
+
titleDivider?: boolean
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export class BoxHost extends SingleChildHost {
|
|
@@ -37,6 +42,8 @@ export class BoxHost extends SingleChildHost {
|
|
|
37
42
|
bg?: Color
|
|
38
43
|
title?: string
|
|
39
44
|
titleColor?: Color
|
|
45
|
+
titleBold = false
|
|
46
|
+
titleDivider = false
|
|
40
47
|
|
|
41
48
|
constructor(props: BoxProps, ctx: HostContext) {
|
|
42
49
|
super("box", props, ctx)
|
|
@@ -52,7 +59,10 @@ export class BoxHost extends SingleChildHost {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
private get insetY(): number {
|
|
55
|
-
|
|
62
|
+
// When titleDivider is enabled: top border + title row + divider line = 3
|
|
63
|
+
// When title but no divider: title embedded in top border = 1
|
|
64
|
+
const titleHeight = this.titleDivider && this.title && this.border !== "none" ? 2 : 0 // title row + divider
|
|
65
|
+
return this.borderThickness + titleHeight + this.padding.top + this.padding.bottom + this.borderThickness
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
measure(maxW: number, maxH: number): Size {
|
|
@@ -85,9 +95,11 @@ export class BoxHost extends SingleChildHost {
|
|
|
85
95
|
const layoutRect = this.layoutWithConstraints(rect)
|
|
86
96
|
|
|
87
97
|
const t = this.borderThickness
|
|
98
|
+
// When titleDivider: title row (1) + divider line (1) = 2
|
|
99
|
+
const titleHeight = this.titleDivider && this.title && this.border !== "none" ? 2 : 0
|
|
88
100
|
const innerRect: Rect = {
|
|
89
101
|
x: layoutRect.x + t + this.padding.left,
|
|
90
|
-
y: layoutRect.y + t + this.padding.top,
|
|
102
|
+
y: layoutRect.y + t + titleHeight + this.padding.top,
|
|
91
103
|
w: Math.max(0, layoutRect.w - this.insetX),
|
|
92
104
|
h: Math.max(0, layoutRect.h - this.insetY),
|
|
93
105
|
}
|
|
@@ -114,18 +126,41 @@ export class BoxHost extends SingleChildHost {
|
|
|
114
126
|
const borderStyle = palette.id({ fg: borderFg })
|
|
115
127
|
drawBorder(buffer, x, y, w, h, chars, borderStyle)
|
|
116
128
|
|
|
117
|
-
// Draw title
|
|
129
|
+
// Draw title if present
|
|
118
130
|
if (this.title && w >= 7) {
|
|
119
131
|
const titleFg = toColorValue(this.titleColor) ?? borderFg
|
|
120
|
-
const titleStyle = palette.id({ fg: titleFg })
|
|
121
|
-
// Reserve
|
|
122
|
-
const maxTitleLen = w -
|
|
132
|
+
const titleStyle = palette.id({ fg: titleFg, bold: this.titleBold })
|
|
133
|
+
// Reserve space for borders and padding: 4 chars (│ + space + space + │)
|
|
134
|
+
const maxTitleLen = w - 4
|
|
123
135
|
const displayTitle =
|
|
124
136
|
this.title.length > maxTitleLen ? this.title.slice(0, maxTitleLen - 1) + "…" : this.title
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
|
|
138
|
+
if (this.titleDivider && h >= 4) {
|
|
139
|
+
// Title in its own row: │ Title │
|
|
140
|
+
const titleRowY = y + 1
|
|
141
|
+
const titleText = ` ${displayTitle}`
|
|
142
|
+
buffer.drawText(x, titleRowY, chars.v, borderStyle, 1)
|
|
143
|
+
buffer.drawText(x + 1, titleRowY, titleText, titleStyle, titleText.length)
|
|
144
|
+
// Fill remaining space and draw right border
|
|
145
|
+
for (let dx = 1 + titleText.length; dx < w - 1; dx++) {
|
|
146
|
+
buffer.drawText(x + dx, titleRowY, " ", borderStyle, 1)
|
|
147
|
+
}
|
|
148
|
+
buffer.drawText(x + w - 1, titleRowY, chars.v, borderStyle, 1)
|
|
149
|
+
|
|
150
|
+
// Divider line: ├───┤
|
|
151
|
+
const dividerY = y + 2
|
|
152
|
+
const tableChars = tableBorderChars(this.border)
|
|
153
|
+
buffer.drawText(x, dividerY, tableChars.lt, borderStyle, 1)
|
|
154
|
+
for (let dx = 1; dx < w - 1; dx++) {
|
|
155
|
+
buffer.drawText(x + dx, dividerY, tableChars.h, borderStyle, 1)
|
|
156
|
+
}
|
|
157
|
+
buffer.drawText(x + w - 1, dividerY, tableChars.rt, borderStyle, 1)
|
|
158
|
+
} else {
|
|
159
|
+
// Title embedded in top border: ┌─ Title ─┐
|
|
160
|
+
const titleX = x + 2
|
|
161
|
+
const titleText = ` ${displayTitle} `
|
|
162
|
+
buffer.drawText(titleX, y, titleText, titleStyle, titleText.length)
|
|
163
|
+
}
|
|
129
164
|
}
|
|
130
165
|
}
|
|
131
166
|
|
|
@@ -151,5 +186,7 @@ export class BoxHost extends SingleChildHost {
|
|
|
151
186
|
this.titleColor = this.resolveSpringProp("titleColor", props.titleColor, (v) => {
|
|
152
187
|
this.titleColor = v as Color
|
|
153
188
|
}) as Color | undefined
|
|
189
|
+
this.titleBold = Boolean(props.titleBold)
|
|
190
|
+
this.titleDivider = Boolean(props.titleDivider)
|
|
154
191
|
}
|
|
155
192
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -120,10 +120,57 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
120
120
|
},
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
+
const handleInlineFullRerender = () => {
|
|
124
|
+
if (mode !== "inline" || !staticRenderer) return
|
|
125
|
+
const inlineMode = renderMode as InlineRenderer
|
|
126
|
+
if (!inlineMode.needsFullRerender()) return
|
|
127
|
+
|
|
128
|
+
// Clear screen + scrollback + cursor home
|
|
129
|
+
stdout.write(ANSI.screen.clear + ANSI.screen.clearScrollback + ANSI.cursor.home)
|
|
130
|
+
// Replay all cached static content
|
|
131
|
+
const cachedStatic = staticRenderer.getCachedOutput()
|
|
132
|
+
if (cachedStatic) {
|
|
133
|
+
stdout.write(cachedStatic)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reset state
|
|
137
|
+
inlineMode.clearFullRerenderFlag()
|
|
138
|
+
state.invalidateBuffers()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const flushInlineStatic = (container: Container | null, frameWidth: number) => {
|
|
142
|
+
if (mode !== "inline" || !container?.staticDirty || !container.staticRoot || !staticRenderer) {
|
|
143
|
+
return ""
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const inlineMode = renderMode as InlineRenderer
|
|
147
|
+
const prevHeight = inlineMode.getPreviousHeight()
|
|
148
|
+
let output = ""
|
|
149
|
+
|
|
150
|
+
// Step 1: Clear the dynamic area (move up + clear to end of screen)
|
|
151
|
+
if (prevHeight > 0) {
|
|
152
|
+
output += ANSI.cursor.up(prevHeight) + ANSI.cursor.startOfLine + ANSI.screen.clearToEnd
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Step 2: Append static content (cursor ends at bottom of static)
|
|
156
|
+
output += staticRenderer.render(container.staticRoot, frameWidth)
|
|
157
|
+
|
|
158
|
+
// Step 3: Reset previousHeight to 0 (we cleared dynamic, starting fresh)
|
|
159
|
+
inlineMode.reset()
|
|
160
|
+
inlineMode.forceFullOutputOnce() // Force full output to resync cursor tracking after static
|
|
161
|
+
state.invalidateBuffers()
|
|
162
|
+
container.staticDirty = false
|
|
163
|
+
|
|
164
|
+
return output
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
// The render frame logic
|
|
124
168
|
const renderFrame = () => {
|
|
125
169
|
const frameStart = Prof.startFrame()
|
|
126
170
|
const frameStartMs = performance.now()
|
|
171
|
+
// Flush any pending React updates before measuring/layout.
|
|
172
|
+
// Ensures resize-driven state (useTerminalSize) is reflected in the host tree.
|
|
173
|
+
flushSync(() => {})
|
|
127
174
|
const frameWidth = state.width
|
|
128
175
|
const frameHeight = state.height
|
|
129
176
|
let contentH = frameHeight
|
|
@@ -137,44 +184,12 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
137
184
|
|
|
138
185
|
try {
|
|
139
186
|
// Handle full rerender on resize (Ink-style: clear everything + replay static)
|
|
140
|
-
|
|
141
|
-
const inlineMode = renderMode as InlineRenderer
|
|
142
|
-
if (inlineMode.needsFullRerender()) {
|
|
143
|
-
// Clear screen + scrollback + cursor home
|
|
144
|
-
stdout.write(ANSI.screen.clear + ANSI.screen.clearScrollback + ANSI.cursor.home)
|
|
145
|
-
// Replay all cached static content
|
|
146
|
-
const cachedStatic = staticRenderer.getCachedOutput()
|
|
147
|
-
if (cachedStatic) {
|
|
148
|
-
stdout.write(cachedStatic)
|
|
149
|
-
}
|
|
150
|
-
// Reset state
|
|
151
|
-
inlineMode.clearFullRerenderFlag()
|
|
152
|
-
state.invalidateBuffers()
|
|
153
|
-
}
|
|
154
|
-
}
|
|
187
|
+
handleInlineFullRerender()
|
|
155
188
|
|
|
156
189
|
// Handle static content: clear dynamic area, append static, then fresh dynamic render
|
|
157
190
|
// Note: IL (insert lines) won't work here because inline mode uses relative positioning
|
|
158
191
|
// and IL would desync the screen state from our buffer tracking.
|
|
159
|
-
|
|
160
|
-
if (mode === "inline" && container?.staticDirty && container?.staticRoot && staticRenderer) {
|
|
161
|
-
const inlineMode = renderMode as InlineRenderer
|
|
162
|
-
const prevHeight = inlineMode.getPreviousHeight()
|
|
163
|
-
|
|
164
|
-
// Step 1: Clear the dynamic area (move up + clear to end of screen)
|
|
165
|
-
if (prevHeight > 0) {
|
|
166
|
-
staticOutput += ANSI.cursor.up(prevHeight) + ANSI.cursor.startOfLine + ANSI.screen.clearToEnd
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Step 2: Append static content (cursor ends at bottom of static)
|
|
170
|
-
staticOutput += staticRenderer.render(container.staticRoot, frameWidth)
|
|
171
|
-
|
|
172
|
-
// Step 3: Reset previousHeight to 0 (we cleared dynamic, starting fresh)
|
|
173
|
-
inlineMode.reset()
|
|
174
|
-
inlineMode.forceFullOutputOnce() // Force full output to resync cursor tracking after static
|
|
175
|
-
state.invalidateBuffers()
|
|
176
|
-
container.staticDirty = false
|
|
177
|
-
}
|
|
192
|
+
const staticOutput = flushInlineStatic(container ?? null, frameWidth)
|
|
178
193
|
|
|
179
194
|
// For inline mode, measure content unconstrained to handle overflow
|
|
180
195
|
let actualContentHeight = frameHeight
|
|
@@ -321,7 +336,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
321
336
|
state.updateDimensions(width, height)
|
|
322
337
|
state.invalidateBuffers()
|
|
323
338
|
state.markDirty()
|
|
324
|
-
|
|
339
|
+
flushSync(() => {
|
|
340
|
+
events.dispatchResize(width, height)
|
|
341
|
+
})
|
|
325
342
|
if (!manualMode) renderFrame()
|
|
326
343
|
},
|
|
327
344
|
}
|
|
@@ -344,7 +361,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
344
361
|
state.invalidateBuffers()
|
|
345
362
|
state.markDirty()
|
|
346
363
|
|
|
347
|
-
|
|
364
|
+
flushSync(() => {
|
|
365
|
+
events.dispatchResize(newWidth, newHeight)
|
|
366
|
+
})
|
|
348
367
|
}
|
|
349
368
|
stdout.on("resize", state.resizeHandler)
|
|
350
369
|
|