@effect-tui/react 0.2.1 → 0.2.2

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.2.1",
3
+ "version": "0.2.2",
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.2.1",
86
+ "@effect-tui/core": "^0.2.2",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -352,5 +352,5 @@ export function TextInput({
352
352
  ],
353
353
  )
354
354
 
355
- return <canvas draw={draw} width={width} height={1} />
355
+ return <canvas draw={draw} width={width} height={1} inheritBg />
356
356
  }
@@ -300,107 +300,99 @@ export function ConsolePopover({
300
300
  [contentStartY, scrollState.offset, displayLines.length],
301
301
  )
302
302
 
303
- // Keyboard handler
304
- useKeyboard(
305
- useCallback(
306
- (key) => {
307
- // Ctrl+Y or Ctrl+C - Copy selection
308
- if (key.ctrl && !key.shift && key.name === "char" && (key.text === "y" || key.text === "c")) {
309
- handleCopy()
310
- return
311
- }
303
+ // Keyboard handler - no useCallback needed, useKeyboard uses ref internally
304
+ useKeyboard((key) => {
305
+ // Ctrl+Y or Ctrl+C - Copy selection
306
+ if (key.ctrl && !key.shift && key.name === "char" && (key.text === "y" || key.text === "c")) {
307
+ handleCopy()
308
+ return
309
+ }
312
310
 
313
- // Ctrl+S - Save logs to file
314
- if (key.ctrl && !key.shift && key.name === "char" && key.text === "s") {
315
- handleSave()
316
- return
317
- }
311
+ // Ctrl+S - Save logs to file
312
+ if (key.ctrl && !key.shift && key.name === "char" && key.text === "s") {
313
+ handleSave()
314
+ return
315
+ }
318
316
 
319
- // Size controls
320
- if (key.name === "char" && (key.text === "+" || key.text === "=")) {
321
- if (mode === "inline") {
322
- setInlineHeight((prev) => Math.min(30, prev + 2))
323
- } else {
324
- setSizePercent((prev) => Math.min(maxHeightPercent, prev + 5))
325
- }
326
- return
327
- }
328
- if (key.name === "char" && key.text === "-") {
329
- if (mode === "inline") {
330
- setInlineHeight((prev) => Math.max(4, prev - 2))
331
- } else {
332
- setSizePercent((prev) => Math.max(minHeightPercent, prev - 5))
333
- }
334
- return
335
- }
317
+ // Size controls
318
+ if (key.name === "char" && (key.text === "+" || key.text === "=")) {
319
+ if (mode === "inline") {
320
+ setInlineHeight((prev) => Math.min(30, prev + 2))
321
+ } else {
322
+ setSizePercent((prev) => Math.min(maxHeightPercent, prev + 5))
323
+ }
324
+ return
325
+ }
326
+ if (key.name === "char" && key.text === "-") {
327
+ if (mode === "inline") {
328
+ setInlineHeight((prev) => Math.max(4, prev - 2))
329
+ } else {
330
+ setSizePercent((prev) => Math.max(minHeightPercent, prev - 5))
331
+ }
332
+ return
333
+ }
336
334
 
337
- // Shift+up/down - jump to top/bottom
338
- if (key.shift && key.name === "up") {
339
- scrollToStart()
340
- return
341
- }
342
- if (key.shift && key.name === "down") {
343
- scrollToEnd()
344
- return
345
- }
335
+ // Shift+up/down - jump to top/bottom
336
+ if (key.shift && key.name === "up") {
337
+ scrollToStart()
338
+ return
339
+ }
340
+ if (key.shift && key.name === "down") {
341
+ scrollToEnd()
342
+ return
343
+ }
346
344
 
347
- // Regular up/down - scroll
348
- if (key.name === "up") {
349
- scrollBy(-1)
350
- return
351
- }
352
- if (key.name === "down") {
353
- scrollBy(1)
354
- return
355
- }
345
+ // Regular up/down - scroll
346
+ if (key.name === "up") {
347
+ scrollBy(-1)
348
+ return
349
+ }
350
+ if (key.name === "down") {
351
+ scrollBy(1)
352
+ return
353
+ }
356
354
 
357
- // Home/End
358
- if (key.name === "home") {
359
- scrollToStart()
360
- return
361
- }
362
- if (key.name === "end") {
363
- scrollToEnd()
364
- return
365
- }
366
- },
367
- [handleCopy, handleSave, maxHeightPercent, minHeightPercent, scrollToStart, scrollToEnd, scrollBy],
368
- ),
369
- )
355
+ // Home/End
356
+ if (key.name === "home") {
357
+ scrollToStart()
358
+ return
359
+ }
360
+ if (key.name === "end") {
361
+ scrollToEnd()
362
+ return
363
+ }
364
+ })
370
365
 
371
- // Mouse handler for text selection
366
+ // Mouse handler for text selection - no useCallback needed, useMouse uses ref internally
372
367
  useMouse(
373
- useCallback(
374
- (mouse) => {
375
- // Only handle events in content area
376
- if (mouse.y < contentStartY || mouse.y >= termHeight) return
377
-
378
- const point = mouseToSelectionPoint(mouse.x, mouse.y)
379
- if (!point) return
380
-
381
- if (mouse.action === "press") {
382
- // Start new selection
383
- setSelectionAnchor(point)
384
- setSelectionHead(point)
385
- isSelectingRef.current = true
386
- } else if (mouse.action === "drag" && isSelectingRef.current) {
387
- // Extend selection
388
- setSelectionHead(point)
389
-
390
- // Auto-scroll when dragging near edges
391
- const relativeY = mouse.y - contentStartY
392
- const viewportHeight = popoverHeight - 1 // -1 for title
393
- if (relativeY <= 1) {
394
- scrollBy(-1)
395
- } else if (relativeY >= viewportHeight - 2) {
396
- scrollBy(1)
397
- }
398
- } else if (mouse.action === "release") {
399
- isSelectingRef.current = false
368
+ (mouse) => {
369
+ // Only handle events in content area
370
+ if (mouse.y < contentStartY || mouse.y >= termHeight) return
371
+
372
+ const point = mouseToSelectionPoint(mouse.x, mouse.y)
373
+ if (!point) return
374
+
375
+ if (mouse.action === "press") {
376
+ // Start new selection
377
+ setSelectionAnchor(point)
378
+ setSelectionHead(point)
379
+ isSelectingRef.current = true
380
+ } else if (mouse.action === "drag" && isSelectingRef.current) {
381
+ // Extend selection
382
+ setSelectionHead(point)
383
+
384
+ // Auto-scroll when dragging near edges
385
+ const relativeY = mouse.y - contentStartY
386
+ const viewportHeight = popoverHeight - 1 // -1 for title
387
+ if (relativeY <= 1) {
388
+ scrollBy(-1)
389
+ } else if (relativeY >= viewportHeight - 2) {
390
+ scrollBy(1)
400
391
  }
401
- },
402
- [contentStartY, termHeight, mouseToSelectionPoint, popoverHeight, scrollBy],
403
- ),
392
+ } else if (mouse.action === "release") {
393
+ isSelectingRef.current = false
394
+ }
395
+ },
404
396
  { action: "any", button: "left" },
405
397
  )
406
398
 
package/src/exit.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Request a clean process exit.
3
+ * This schedules process.exit() on the next tick to allow
4
+ * pending cleanup operations to complete.
5
+ */
6
+ export function requestExit(code = 0): void {
7
+ setImmediate(() => process.exit(code))
8
+ }
@@ -1,4 +1,5 @@
1
1
  import { useCallback } from "react"
2
+ import { requestExit } from "../exit.js"
2
3
  import { useRenderer } from "../renderer-context.js"
3
4
 
4
5
  /**
@@ -26,7 +27,7 @@ export function useQuit(): (code?: number) => void {
26
27
  return useCallback(
27
28
  (code = 0) => {
28
29
  renderer.stop()
29
- process.exit(code)
30
+ requestExit(code)
30
31
  },
31
32
  [renderer],
32
33
  )
@@ -6,6 +6,7 @@ import {
6
6
  borderChars,
7
7
  drawBorder,
8
8
  resolveBgStyle,
9
+ resolveInheritedBgStyle,
9
10
  styleIdFromProps,
10
11
  toColorValue,
11
12
  } from "../utils/index.js"
@@ -62,12 +63,15 @@ export interface CanvasProps extends CommonProps {
62
63
  width?: number
63
64
  /** Fixed height (default: fill available) */
64
65
  height?: number
66
+ /** Inherit background color from parent and pre-fill canvas (default: false) */
67
+ inheritBg?: boolean
65
68
  }
66
69
 
67
70
  export class CanvasHost extends BaseHost {
68
71
  draw: CanvasProps["draw"] = () => {}
69
72
  fixedWidth?: number
70
73
  fixedHeight?: number
74
+ inheritBg = false
71
75
 
72
76
  constructor(props: CanvasProps, ctx: HostContext) {
73
77
  super("canvas", props, ctx)
@@ -85,6 +89,12 @@ export class CanvasHost extends BaseHost {
85
89
  if (!this.rect) return
86
90
  const { x: ox, y: oy, w, h } = this.rect
87
91
 
92
+ // Pre-fill with inherited background if requested
93
+ if (this.inheritBg) {
94
+ const { styleId } = resolveInheritedBgStyle(palette, undefined, this.parent)
95
+ buffer.fillRect(ox, oy, w, h, " ".codePointAt(0)!, styleId)
96
+ }
97
+
88
98
  // Create draw context
89
99
  const ctx: DrawContext = {
90
100
  width: w,
@@ -180,5 +190,6 @@ export class CanvasHost extends BaseHost {
180
190
  }
181
191
  if (props.width !== undefined) this.fixedWidth = props.width as number
182
192
  if (props.height !== undefined) this.fixedHeight = props.height as number
193
+ if (props.inheritBg !== undefined) this.inheritBg = props.inheritBg as boolean
183
194
  }
184
195
  }
@@ -75,6 +75,12 @@ export interface RendererOptions {
75
75
  mode?: "fullscreen" | "inline"
76
76
  /** Exit the process on Ctrl+C unless preventDefault was called. Defaults to true. */
77
77
  exitOnCtrlC?: boolean
78
+ /** Handle SIGINT/SIGTERM and restore terminal. Defaults to true. */
79
+ handleSignals?: boolean
80
+ /** Exit the process after handling SIGINT/SIGTERM. Defaults to true. */
81
+ exitOnSignal?: boolean
82
+ /** Override exit codes for signals. Defaults: SIGINT=130, SIGTERM=143. */
83
+ signalExitCodes?: Partial<Record<"SIGINT" | "SIGTERM", number>>
78
84
  /** Enable diffed rendering (per-line). Defaults to true in runtime, false in manualMode (tests). */
79
85
  diff?: boolean
80
86
  /** Skip automatic render loop. Call flush() manually to render frames. */
package/src/renderer.ts CHANGED
@@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks"
2
2
  import { ANSI, bufferToString, type KeyMsg, type MouseMsg } from "@effect-tui/core"
3
3
  import React, { type ReactNode } from "react"
4
4
  import { DEFAULT_FPS } from "./constants.js"
5
+ import { requestExit } from "./exit.js"
5
6
  import * as Prof from "./profiler.js"
6
7
  import { flushSync, reconciler } from "./reconciler/host-config.js"
7
8
  import type { HostContext } from "./reconciler/types.js"
@@ -25,12 +26,21 @@ export { RendererContext, useRenderer, useTerminalSize } from "./renderer-contex
25
26
  // Re-export types and context for backwards compatibility
26
27
  export type { FrameStats, RendererOptions, TuiReadStream, TuiRenderer, TuiWriteStream } from "./renderer-types.js"
27
28
 
29
+ type HandledSignal = "SIGINT" | "SIGTERM"
30
+
28
31
  export function createRenderer(options?: RendererOptions): TuiRenderer {
29
32
  const fps = options?.fps ?? DEFAULT_FPS
30
33
  const stdout: TuiWriteStream = options?.stdout ?? process.stdout
31
34
  const stdin: TuiReadStream = options?.stdin ?? process.stdin
32
35
  const mode = options?.mode ?? "fullscreen"
33
36
  const exitOnCtrlC = options?.exitOnCtrlC ?? true
37
+ const handleSignals = options?.handleSignals ?? true
38
+ const exitOnSignal = options?.exitOnSignal ?? true
39
+ const signalExitCodes: Record<HandledSignal, number> = {
40
+ SIGINT: 130,
41
+ SIGTERM: 143,
42
+ ...options?.signalExitCodes,
43
+ }
34
44
  const manualMode = options?.manualMode ?? false
35
45
  const enableDiff = options?.diff ?? !manualMode
36
46
  const skipTerminalSetup = options?.skipTerminalSetup ?? false
@@ -73,7 +83,7 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
73
83
  onQuit: () => {
74
84
  // Clean up terminal state before exiting
75
85
  renderer.stop()
76
- process.exit(0)
86
+ requestExit(0)
77
87
  },
78
88
  })
79
89
 
@@ -210,6 +220,9 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
210
220
  }
211
221
  }
212
222
 
223
+ let onExit: (() => void) | null = null
224
+ let onSignal: ((signal: NodeJS.Signals) => void) | null = null
225
+
213
226
  // Build renderer object
214
227
  const renderer: TuiRenderer = {
215
228
  get width() {
@@ -228,6 +241,15 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
228
241
  onFrameStats: (handler: (stats: FrameStats) => void) => events.onFrameStats(handler),
229
242
  stop() {
230
243
  state.running = false
244
+ if (onExit) {
245
+ process.off("exit", onExit)
246
+ onExit = null
247
+ }
248
+ if (onSignal) {
249
+ process.off("SIGINT", onSignal)
250
+ process.off("SIGTERM", onSignal)
251
+ onSignal = null
252
+ }
231
253
  if (state.loop) {
232
254
  clearInterval(state.loop)
233
255
  state.loop = null
@@ -314,16 +336,20 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
314
336
  }
315
337
 
316
338
  // Handle normal process exit (synchronous - runs before exit completes)
317
- const onExit = () => cleanup()
339
+ onExit = () => cleanup()
318
340
  process.on("exit", onExit)
319
341
 
320
- // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
321
- const onSignal = () => {
322
- cleanup()
323
- process.exit(0)
342
+ if (handleSignals) {
343
+ // Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
344
+ onSignal = (signal: NodeJS.Signals) => {
345
+ cleanup()
346
+ if (!exitOnSignal) return
347
+ const code = signalExitCodes[signal as HandledSignal] ?? 0
348
+ requestExit(code)
349
+ }
350
+ process.on("SIGINT", onSignal)
351
+ process.on("SIGTERM", onSignal)
324
352
  }
325
- process.on("SIGINT", onSignal)
326
- process.on("SIGTERM", onSignal)
327
353
 
328
354
  ;(renderer as TuiRendererInternal)._container = null
329
355
  return renderer
@@ -435,7 +461,7 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
435
461
  // The createRenderer exit handler will also run, but it's idempotent
436
462
  const quit = (code = 0) => {
437
463
  renderer.stop()
438
- process.exit(code)
464
+ requestExit(code)
439
465
  }
440
466
 
441
467
  return {
@@ -17,6 +17,13 @@ export interface FlexMeasureResult {
17
17
  /**
18
18
  * Measure children along a flex axis.
19
19
  * Returns cached sizes and total size.
20
+ *
21
+ * SwiftUI-style measure:
22
+ * 1. First measure all non-greedy children with full available space
23
+ * 2. Then measure greedy children with remaining space
24
+ *
25
+ * This ensures non-greedy elements always get their natural size,
26
+ * even when sibling greedy elements have large content.
20
27
  */
21
28
  export function measureFlex(
22
29
  axis: FlexAxis,
@@ -25,26 +32,58 @@ export function measureFlex(
25
32
  maxMain: number,
26
33
  maxCross: number,
27
34
  ): FlexMeasureResult {
28
- const sizes: Size[] = []
29
- let totalMain = 0
35
+ const sizes: Size[] = new Array(children.length)
30
36
  let maxChildCross = 0
37
+ const totalSpacing = Math.max(0, (children.length - 1) * spacing)
31
38
 
39
+ // Pass 1: Measure non-greedy children first with full available space
40
+ let nonGreedyTotal = 0
32
41
  for (let i = 0; i < children.length; i++) {
33
42
  const child = children[i]
34
- const remainingMain = Math.max(0, maxMain - totalMain)
43
+ const greedyWeight = getGreedyWeight(child)
44
+
45
+ if (greedyWeight === 0) {
46
+ // Non-greedy: measure with full available space
47
+ const childMaxW = axis === "vertical" ? maxCross : maxMain
48
+ const childMaxH = axis === "vertical" ? maxMain : maxCross
49
+ const size = child.measure(childMaxW, childMaxH)
50
+ sizes[i] = size
51
+ nonGreedyTotal += mainSize(axis, size)
52
+ maxChildCross = Math.max(maxChildCross, crossSize(axis, size))
53
+ }
54
+ }
35
55
 
36
- // Convert main/cross back to width/height for child measure
37
- const childMaxW = axis === "vertical" ? maxCross : remainingMain
38
- const childMaxH = axis === "vertical" ? remainingMain : maxCross
56
+ // Pass 2: Measure greedy children with remaining space
57
+ const remainingForGreedy = Math.max(0, maxMain - nonGreedyTotal - totalSpacing)
58
+ let totalGreedyWeight = 0
59
+ for (let i = 0; i < children.length; i++) {
60
+ const greedyWeight = getGreedyWeight(children[i])
61
+ if (greedyWeight > 0) totalGreedyWeight += greedyWeight
62
+ }
39
63
 
40
- const size = child.measure(childMaxW, childMaxH)
41
- sizes.push(size)
64
+ let greedyMeasuredTotal = 0
65
+ for (let i = 0; i < children.length; i++) {
66
+ const child = children[i]
67
+ const greedyWeight = getGreedyWeight(child)
42
68
 
43
- totalMain += mainSize(axis, size)
44
- if (i < children.length - 1) totalMain += spacing
45
- maxChildCross = Math.max(maxChildCross, crossSize(axis, size))
69
+ if (greedyWeight > 0) {
70
+ // Greedy: measure with proportional share of remaining space
71
+ const greedyMain = totalGreedyWeight > 0
72
+ ? (remainingForGreedy * greedyWeight) / totalGreedyWeight
73
+ : remainingForGreedy
74
+ const childMaxW = axis === "vertical" ? maxCross : greedyMain
75
+ const childMaxH = axis === "vertical" ? greedyMain : maxCross
76
+ const size = child.measure(childMaxW, childMaxH)
77
+ sizes[i] = size
78
+ greedyMeasuredTotal += mainSize(axis, size)
79
+ maxChildCross = Math.max(maxChildCross, crossSize(axis, size))
80
+ }
46
81
  }
47
82
 
83
+ // Calculate total main dimension
84
+ // Use actual measured sizes, not the constraint
85
+ let totalMain = nonGreedyTotal + greedyMeasuredTotal + totalSpacing
86
+
48
87
  // Build total size from main/cross dimensions
49
88
  const totalW = axis === "vertical" ? maxChildCross : totalMain
50
89
  const totalH = axis === "vertical" ? totalMain : maxChildCross
@@ -68,6 +107,13 @@ function getGreedyWeight(child: HostInstance): number {
68
107
 
69
108
  /**
70
109
  * Layout children along a flex axis using cached sizes.
110
+ *
111
+ * SwiftUI-style layout:
112
+ * 1. Non-greedy children get their natural (measured) size
113
+ * 2. Greedy children share the REMAINING space proportionally
114
+ *
115
+ * This ensures non-greedy elements (like footers) are always visible,
116
+ * even when greedy elements (like scroll views) have overflowing content.
71
117
  */
72
118
  export function layoutFlex(
73
119
  axis: FlexAxis,
@@ -78,23 +124,29 @@ export function layoutFlex(
78
124
  alignment: FlexAlignment,
79
125
  stretchCross: boolean,
80
126
  ): void {
81
- // Calculate totals
82
- let totalNaturalMain = 0
83
- let totalGreedyWeight = 0
84
127
  const totalSpacing = Math.max(0, (children.length - 1) * spacing)
128
+ const availableMain = mainDim(axis, rect)
129
+
130
+ // Pass 1: Calculate space needed by non-greedy children
131
+ let nonGreedyTotal = 0
132
+ let totalGreedyWeight = 0
85
133
 
86
134
  for (let i = 0; i < children.length; i++) {
87
135
  const child = children[i]
88
136
  const size = cachedSizes[i] ?? child.measure(rect.w, rect.h)
89
- totalNaturalMain += mainSize(axis, size)
90
- totalGreedyWeight += getGreedyWeight(child)
137
+ const greedyWeight = getGreedyWeight(child)
138
+
139
+ if (greedyWeight === 0) {
140
+ // Non-greedy: use natural size
141
+ nonGreedyTotal += mainSize(axis, size)
142
+ }
143
+ totalGreedyWeight += greedyWeight
91
144
  }
92
145
 
93
- // Calculate extra space to distribute to greedy children
94
- const availableMain = mainDim(axis, rect)
95
- const extraSpace = Math.max(0, availableMain - totalNaturalMain - totalSpacing)
146
+ // Remaining space for greedy children (after non-greedy + spacing)
147
+ const remainingForGreedy = Math.max(0, availableMain - nonGreedyTotal - totalSpacing)
96
148
 
97
- // Layout children
149
+ // Pass 2: Layout all children
98
150
  let curMainPos = mainPos(axis, rect)
99
151
  const crossStartPos = crossPos(axis, rect)
100
152
  const crossDimVal = crossDim(axis, rect)
@@ -103,9 +155,16 @@ export function layoutFlex(
103
155
  const child = children[i]
104
156
  const size = cachedSizes[i] ?? { w: 0, h: 0 }
105
157
  const greedyWeight = getGreedyWeight(child)
106
- const greedyExtra = totalGreedyWeight > 0 ? (extraSpace * greedyWeight) / totalGreedyWeight : 0
107
158
 
108
- const childMainDim = mainSize(axis, size) + greedyExtra
159
+ let childMainDim: number
160
+ if (greedyWeight > 0 && totalGreedyWeight > 0) {
161
+ // Greedy: gets proportional share of remaining space
162
+ childMainDim = (remainingForGreedy * greedyWeight) / totalGreedyWeight
163
+ } else {
164
+ // Non-greedy: gets natural size
165
+ childMainDim = mainSize(axis, size)
166
+ }
167
+
109
168
  const childCrossDim = crossSize(axis, size)
110
169
 
111
170
  // Calculate cross position based on alignment