@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.14.3",
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.14.3",
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
- return this.borderThickness + this.padding.top + this.padding.bottom + this.borderThickness
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 on top border if present
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: ┌─ (2) + space before (1) + space after (1) + ─┐ (2) = 6 chars
122
- const maxTitleLen = w - 6
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
- // Draw " Title " starting at x+2, limiting width to just the title text
126
- const titleX = x + 2
127
- const titleText = ` ${displayTitle} `
128
- buffer.drawText(titleX, y, titleText, titleStyle, titleText.length)
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
- if (mode === "inline" && staticRenderer) {
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
- let staticOutput = ""
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
- events.dispatchResize(width, height)
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
- events.dispatchResize(newWidth, newHeight)
364
+ flushSync(() => {
365
+ events.dispatchResize(newWidth, newHeight)
366
+ })
348
367
  }
349
368
  stdout.on("resize", state.resizeHandler)
350
369