@effect-tui/react 0.1.6 → 0.2.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.
Files changed (119) hide show
  1. package/dist/src/dev.js +2 -2
  2. package/dist/src/dev.js.map +1 -1
  3. package/dist/src/hooks/index.d.ts +1 -0
  4. package/dist/src/hooks/index.d.ts.map +1 -1
  5. package/dist/src/hooks/index.js +1 -0
  6. package/dist/src/hooks/index.js.map +1 -1
  7. package/dist/src/hooks/use-quit.d.ts +21 -0
  8. package/dist/src/hooks/use-quit.d.ts.map +1 -0
  9. package/dist/src/hooks/use-quit.js +29 -0
  10. package/dist/src/hooks/use-quit.js.map +1 -0
  11. package/dist/src/hosts/base.d.ts +6 -2
  12. package/dist/src/hosts/base.d.ts.map +1 -1
  13. package/dist/src/hosts/base.js +56 -6
  14. package/dist/src/hosts/base.js.map +1 -1
  15. package/dist/src/hosts/box.d.ts.map +1 -1
  16. package/dist/src/hosts/box.js +3 -5
  17. package/dist/src/hosts/box.js.map +1 -1
  18. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  19. package/dist/src/hosts/flex-container.js +4 -1
  20. package/dist/src/hosts/flex-container.js.map +1 -1
  21. package/dist/src/hosts/overlay-item.d.ts +2 -2
  22. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  23. package/dist/src/hosts/overlay-item.js +7 -22
  24. package/dist/src/hosts/overlay-item.js.map +1 -1
  25. package/dist/src/hosts/overlay.d.ts.map +1 -1
  26. package/dist/src/hosts/overlay.js +3 -35
  27. package/dist/src/hosts/overlay.js.map +1 -1
  28. package/dist/src/hosts/scroll.d.ts +1 -0
  29. package/dist/src/hosts/scroll.d.ts.map +1 -1
  30. package/dist/src/hosts/scroll.js +14 -7
  31. package/dist/src/hosts/scroll.js.map +1 -1
  32. package/dist/src/hosts/spacer.d.ts +1 -0
  33. package/dist/src/hosts/spacer.d.ts.map +1 -1
  34. package/dist/src/hosts/spacer.js +10 -9
  35. package/dist/src/hosts/spacer.js.map +1 -1
  36. package/dist/src/hosts/text.js +2 -2
  37. package/dist/src/hosts/text.js.map +1 -1
  38. package/dist/src/hosts/zstack.d.ts +3 -2
  39. package/dist/src/hosts/zstack.d.ts.map +1 -1
  40. package/dist/src/hosts/zstack.js +3 -18
  41. package/dist/src/hosts/zstack.js.map +1 -1
  42. package/dist/src/reconciler/types.d.ts +9 -4
  43. package/dist/src/reconciler/types.d.ts.map +1 -1
  44. package/dist/src/remote/Procedures.d.ts +4 -0
  45. package/dist/src/remote/Procedures.d.ts.map +1 -1
  46. package/dist/src/remote/Procedures.js +4 -0
  47. package/dist/src/remote/Procedures.js.map +1 -1
  48. package/dist/src/remote/Router.d.ts +2 -0
  49. package/dist/src/remote/Router.d.ts.map +1 -1
  50. package/dist/src/remote/Router.js.map +1 -1
  51. package/dist/src/remote/index.d.ts +8 -2
  52. package/dist/src/remote/index.d.ts.map +1 -1
  53. package/dist/src/remote/index.js +31 -3
  54. package/dist/src/remote/index.js.map +1 -1
  55. package/dist/src/renderer/input/InputProcessor.d.ts +1 -0
  56. package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
  57. package/dist/src/renderer/input/InputProcessor.js +6 -1
  58. package/dist/src/renderer/input/InputProcessor.js.map +1 -1
  59. package/dist/src/renderer/lifecycle/TerminalSetup.d.ts +1 -0
  60. package/dist/src/renderer/lifecycle/TerminalSetup.d.ts.map +1 -1
  61. package/dist/src/renderer/lifecycle/TerminalSetup.js +26 -17
  62. package/dist/src/renderer/lifecycle/TerminalSetup.js.map +1 -1
  63. package/dist/src/renderer/modes/FullscreenRenderer.d.ts.map +1 -1
  64. package/dist/src/renderer/modes/FullscreenRenderer.js +8 -6
  65. package/dist/src/renderer/modes/FullscreenRenderer.js.map +1 -1
  66. package/dist/src/renderer/modes/InlineRenderer.d.ts.map +1 -1
  67. package/dist/src/renderer/modes/InlineRenderer.js +9 -7
  68. package/dist/src/renderer/modes/InlineRenderer.js.map +1 -1
  69. package/dist/src/renderer.d.ts +2 -0
  70. package/dist/src/renderer.d.ts.map +1 -1
  71. package/dist/src/renderer.js +39 -7
  72. package/dist/src/renderer.js.map +1 -1
  73. package/dist/src/test/render-tui.d.ts +5 -0
  74. package/dist/src/test/render-tui.d.ts.map +1 -1
  75. package/dist/src/test/render-tui.js +3 -0
  76. package/dist/src/test/render-tui.js.map +1 -1
  77. package/dist/src/utils/alignment.d.ts +15 -0
  78. package/dist/src/utils/alignment.d.ts.map +1 -0
  79. package/dist/src/utils/alignment.js +37 -0
  80. package/dist/src/utils/alignment.js.map +1 -0
  81. package/dist/src/utils/flex-layout.d.ts.map +1 -1
  82. package/dist/src/utils/flex-layout.js +20 -6
  83. package/dist/src/utils/flex-layout.js.map +1 -1
  84. package/dist/src/utils/index.d.ts +2 -1
  85. package/dist/src/utils/index.d.ts.map +1 -1
  86. package/dist/src/utils/index.js +2 -1
  87. package/dist/src/utils/index.js.map +1 -1
  88. package/dist/src/utils/styles.d.ts +9 -0
  89. package/dist/src/utils/styles.d.ts.map +1 -1
  90. package/dist/src/utils/styles.js +9 -0
  91. package/dist/src/utils/styles.js.map +1 -1
  92. package/dist/tsconfig.tsbuildinfo +1 -1
  93. package/package.json +3 -2
  94. package/src/dev.tsx +2 -2
  95. package/src/hooks/index.ts +1 -0
  96. package/src/hooks/use-quit.ts +33 -0
  97. package/src/hosts/base.ts +50 -6
  98. package/src/hosts/box.ts +3 -6
  99. package/src/hosts/flex-container.ts +3 -1
  100. package/src/hosts/overlay-item.ts +7 -22
  101. package/src/hosts/overlay.ts +3 -37
  102. package/src/hosts/scroll.ts +16 -7
  103. package/src/hosts/spacer.ts +11 -9
  104. package/src/hosts/text.ts +3 -3
  105. package/src/hosts/zstack.ts +5 -18
  106. package/src/reconciler/types.ts +9 -4
  107. package/src/remote/Procedures.ts +4 -0
  108. package/src/remote/Router.ts +7 -1
  109. package/src/remote/index.ts +41 -3
  110. package/src/renderer/input/InputProcessor.ts +6 -1
  111. package/src/renderer/lifecycle/TerminalSetup.ts +28 -17
  112. package/src/renderer/modes/FullscreenRenderer.ts +8 -6
  113. package/src/renderer/modes/InlineRenderer.ts +9 -7
  114. package/src/renderer.ts +44 -6
  115. package/src/test/render-tui.ts +7 -0
  116. package/src/utils/alignment.ts +50 -0
  117. package/src/utils/flex-layout.ts +19 -6
  118. package/src/utils/index.ts +10 -1
  119. package/src/utils/styles.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.1.6",
3
+ "version": "0.2.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.1.6",
86
+ "@effect-tui/core": "^0.2.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -110,6 +110,7 @@
110
110
  }
111
111
  },
112
112
  "devDependencies": {
113
+ "@effect-tui/emulator": "workspace:^",
113
114
  "@effect/opentelemetry": "^0.60.0",
114
115
  "@effect/vitest": "^0.27.0",
115
116
  "@opentelemetry/api": "^1.9.0",
package/src/dev.tsx CHANGED
@@ -395,8 +395,8 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
395
395
  const renderer = createRenderer(options)
396
396
  const root = createRoot(renderer)
397
397
 
398
- // Dev mode always enables remote control
399
- const stopRemote = enableRemote(renderer)
398
+ // Dev mode always enables remote control with entry path for identification
399
+ const stopRemote = enableRemote(renderer, { entryPath })
400
400
 
401
401
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
402
402
  let version = 0
@@ -3,5 +3,6 @@ export type { UseKeyboardOptions } from "./use-keyboard.js"
3
3
  export { useMouse } from "./use-mouse.js"
4
4
  export type { UseMouseOptions } from "./use-mouse.js"
5
5
  export { usePaste } from "./use-paste.js"
6
+ export { useQuit } from "./use-quit.js"
6
7
  export { useScroll } from "./use-scroll.js"
7
8
  export type { UseScrollOptions, UseScrollReturn, ScrollState } from "./use-scroll.js"
@@ -0,0 +1,33 @@
1
+ import { useCallback } from "react"
2
+ import { useRenderer } from "../renderer-context.js"
3
+
4
+ /**
5
+ * Hook that returns a quit function for cleanly exiting the TUI application.
6
+ *
7
+ * This ensures terminal state is properly restored (alternate buffer exit,
8
+ * cursor shown, raw mode disabled) before the process exits.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * function App() {
13
+ * const quit = useQuit()
14
+ *
15
+ * useKeyboard((key) => {
16
+ * if (key.text === "q") quit()
17
+ * })
18
+ *
19
+ * return <text>Press q to quit</text>
20
+ * }
21
+ * ```
22
+ */
23
+ export function useQuit(): (code?: number) => void {
24
+ const renderer = useRenderer()
25
+
26
+ return useCallback(
27
+ (code = 0) => {
28
+ renderer.stop()
29
+ process.exit(code)
30
+ },
31
+ [renderer],
32
+ )
33
+ }
package/src/hosts/base.ts CHANGED
@@ -32,9 +32,11 @@ export abstract class BaseHost implements HostInstance {
32
32
  children: HostInstance[] = []
33
33
  rect: Rect | null = null
34
34
 
35
- // Common flex props
36
- flexGrow = 0
37
- flexShrink = 1
35
+ // Greedy layout - expands to fill remaining space
36
+ // undefined = not greedy (hug content)
37
+ // true or 1 = 1 share
38
+ // number > 1 = proportionally more shares
39
+ greedy: boolean | number | undefined = undefined
38
40
 
39
41
  // ─────────────────────────────────────────────────────────────
40
42
  // Frame constraints (like SwiftUI's .frame modifier)
@@ -65,7 +67,20 @@ export abstract class BaseHost implements HostInstance {
65
67
  abstract render(buffer: CellBuffer, palette: Palette): void
66
68
 
67
69
  layout(rect: Rect): void {
68
- this.rect = rect
70
+ // Apply frame constraints to the assigned rect
71
+ let { w, h } = rect
72
+
73
+ // Fixed width/height override
74
+ if (this.frameWidth !== undefined) w = this.frameWidth
75
+ if (this.frameHeight !== undefined) h = this.frameHeight
76
+
77
+ // Apply min/max clamps
78
+ if (this.frameMinWidth !== undefined) w = Math.max(this.frameMinWidth, w)
79
+ if (this.frameMaxWidth !== undefined) w = Math.min(this.frameMaxWidth, w)
80
+ if (this.frameMinHeight !== undefined) h = Math.max(this.frameMinHeight, h)
81
+ if (this.frameMaxHeight !== undefined) h = Math.min(this.frameMaxHeight, h)
82
+
83
+ this.rect = { x: rect.x, y: rect.y, w, h }
69
84
  }
70
85
 
71
86
  /**
@@ -144,8 +159,23 @@ export abstract class BaseHost implements HostInstance {
144
159
  }
145
160
 
146
161
  updateProps(props: Record<string, unknown>): void {
147
- this.flexGrow = (props.flexGrow as number | undefined) ?? 0
148
- this.flexShrink = (props.flexShrink as number | undefined) ?? 1
162
+ // Greedy layout - reset to undefined unless explicitly set
163
+ // Subclasses like Spacer/Scroll set their own default before calling super
164
+ if ("greedy" in props) {
165
+ const greedy = props.greedy
166
+ if (greedy === true) {
167
+ this.greedy = 1
168
+ } else if (typeof greedy === "number") {
169
+ this.greedy = greedy
170
+ } else {
171
+ // greedy is false, undefined, or null
172
+ this.greedy = undefined
173
+ }
174
+ } else {
175
+ // No greedy prop - reset to default (undefined)
176
+ // Subclasses like Spacer/Scroll handle this before calling super
177
+ this.greedy = undefined
178
+ }
149
179
 
150
180
  // Frame constraints - only accept valid numbers (ignore strings like "100%")
151
181
  this.frameWidth = typeof props.width === "number" ? props.width : undefined
@@ -183,4 +213,18 @@ export abstract class BaseHost implements HostInstance {
183
213
  }
184
214
  child.parent = this
185
215
  }
216
+
217
+ /**
218
+ * Render a child with optional clipping to its rect.
219
+ * Common pattern used by containers (ZStack, Overlay, etc.)
220
+ */
221
+ protected renderChildWithClip(child: HostInstance, buffer: CellBuffer, palette: Palette): void {
222
+ if (child.rect) {
223
+ buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
224
+ child.render(buffer, palette)
225
+ })
226
+ } else {
227
+ child.render(buffer, palette)
228
+ }
229
+ }
186
230
  }
package/src/hosts/box.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { CellBuffer, Palette, Color } from "@effect-tui/core"
2
2
  import { Colors } from "@effect-tui/core"
3
3
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
4
- import { getInheritedBg } from "./base.js"
5
4
  import { SingleChildHost } from "./single-child.js"
6
5
  import {
7
6
  type BorderKind,
@@ -10,7 +9,7 @@ import {
10
9
  type Padding,
11
10
  type PaddingInput,
12
11
  resolvePadding,
13
- resolveBgStyle,
12
+ resolveInheritedBgStyle,
14
13
  toColorValue,
15
14
  } from "../utils/index.js"
16
15
 
@@ -94,11 +93,9 @@ export class BoxHost extends SingleChildHost {
94
93
  if (!this.rect) return
95
94
  const { x, y, w, h } = this.rect
96
95
 
97
- // If no explicit bg, inherit from parent (so child boxes are transparent over parents)
98
- const rawBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
99
-
100
96
  // Always clear our rect to ensure stale backgrounds are removed.
101
- const { styleId: bgStyleId } = resolveBgStyle(palette, rawBg)
97
+ // If no explicit bg, inherit from parent (so child boxes are transparent over parents)
98
+ const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
102
99
  buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
103
100
 
104
101
  // Draw border
@@ -84,12 +84,14 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
84
84
 
85
85
  override layout(rect: Rect): void {
86
86
  super.layout(rect)
87
+ // Use this.rect (constrained) not rect (raw input)
88
+ if (!this.rect) return
87
89
  const stretchCross = this.axis === "vertical" ? this.alignment === "leading" : this.alignment === "top"
88
90
  layoutFlex(
89
91
  this.axis,
90
92
  this.layoutChildren,
91
93
  this.cachedSizes,
92
- rect,
94
+ this.rect,
93
95
  this.spacing,
94
96
  toFlexAlignment(this.axis, this.alignment),
95
97
  stretchCross,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { CellBuffer, Palette, Rect, Size } from "@effect-tui/core"
9
9
  import type { HostContext, CommonProps } from "../reconciler/types.js"
10
- import { BaseHost } from "./base.js"
10
+ import { SingleChildHost } from "./single-child.js"
11
11
 
12
12
  type HAlign = "left" | "center" | "right"
13
13
  type VAlign = "top" | "center" | "bottom"
@@ -17,7 +17,7 @@ export interface OverlayItemProps extends CommonProps {
17
17
  alignment?: { h?: HAlign; v?: VAlign }
18
18
  }
19
19
 
20
- export class OverlayItemHost extends BaseHost {
20
+ export class OverlayItemHost extends SingleChildHost {
21
21
  /** Stored alignment for parent Overlay to read */
22
22
  alignment: { h?: HAlign; v?: VAlign } = {}
23
23
 
@@ -29,37 +29,22 @@ export class OverlayItemHost extends BaseHost {
29
29
  override measure(maxW: number, maxH: number): Size {
30
30
  const constrained = this.constrainProposal(maxW, maxH)
31
31
 
32
- // Size is determined by single child
33
- const child = this.children[0]
34
- if (!child) {
32
+ if (!this.child) {
35
33
  return this.constrainResult({ w: 0, h: 0 })
36
34
  }
37
35
 
38
- const childSize = child.measure(constrained.w, constrained.h)
36
+ const childSize = this.child.measure(constrained.w, constrained.h)
39
37
  return this.constrainResult(childSize)
40
38
  }
41
39
 
42
40
  override layout(rect: Rect): void {
43
41
  this.rect = rect
44
-
45
- // Pass rect to child
46
- const child = this.children[0]
47
- if (child) {
48
- child.layout(rect)
49
- }
42
+ this.child?.layout(rect)
50
43
  }
51
44
 
52
45
  override render(buffer: CellBuffer, palette: Palette): void {
53
- // Render child
54
- const child = this.children[0]
55
- if (child) {
56
- if (child.rect) {
57
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
58
- child.render(buffer, palette)
59
- })
60
- } else {
61
- child.render(buffer, palette)
62
- }
46
+ if (this.child) {
47
+ this.renderChildWithClip(this.child, buffer, palette)
63
48
  }
64
49
  }
65
50
 
@@ -19,6 +19,7 @@
19
19
  import type { CellBuffer, Palette, Rect, Size } from "@effect-tui/core"
20
20
  import type { HostContext, CommonProps } from "../reconciler/types.js"
21
21
  import { BaseHost } from "./base.js"
22
+ import { alignInRect } from "../utils/index.js"
22
23
  import type { OverlayItemHost } from "./overlay-item.js"
23
24
 
24
25
  export interface OverlayProps extends CommonProps {}
@@ -70,36 +71,7 @@ export class OverlayHost extends BaseHost {
70
71
 
71
72
  // Read alignment from OverlayItemHost (use type check, not instanceof, for bundler compatibility)
72
73
  const alignment = child.type === "overlayItem" ? (child as OverlayItemHost).alignment : {}
73
- const hAlign = alignment.h ?? "center"
74
- const vAlign = alignment.v ?? "center"
75
-
76
- // Calculate position based on alignment
77
- let x = rect.x
78
- let y = rect.y
79
-
80
- switch (hAlign) {
81
- case "left":
82
- x = rect.x
83
- break
84
- case "center":
85
- x = rect.x + Math.floor((rect.w - size.w) / 2)
86
- break
87
- case "right":
88
- x = rect.x + rect.w - size.w
89
- break
90
- }
91
-
92
- switch (vAlign) {
93
- case "top":
94
- y = rect.y
95
- break
96
- case "center":
97
- y = rect.y + Math.floor((rect.h - size.h) / 2)
98
- break
99
- case "bottom":
100
- y = rect.y + rect.h - size.h
101
- break
102
- }
74
+ const { x, y } = alignInRect(rect, size, alignment.h ?? "center", alignment.v ?? "center")
103
75
 
104
76
  child.layout({
105
77
  x,
@@ -113,13 +85,7 @@ export class OverlayHost extends BaseHost {
113
85
  override render(buffer: CellBuffer, palette: Palette): void {
114
86
  // Render all children in order (base first, then overlays)
115
87
  for (const child of this.children) {
116
- if (child.rect) {
117
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
118
- child.render(buffer, palette)
119
- })
120
- } else {
121
- child.render(buffer, palette)
122
- }
88
+ this.renderChildWithClip(child, buffer, palette)
123
89
  }
124
90
  }
125
91
  }
@@ -1,9 +1,8 @@
1
1
  // scroll.ts — Scrollable container host
2
2
  import type { CellBuffer, Palette, Color } from "@effect-tui/core"
3
3
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
4
- import { getInheritedBg } from "./base.js"
5
4
  import { SingleChildHost } from "./single-child.js"
6
- import { resolveBgStyle } from "../utils/index.js"
5
+ import { resolveInheritedBgStyle } from "../utils/index.js"
7
6
 
8
7
  export interface ScrollProps extends CommonProps {
9
8
  /** Scroll axis: "vertical" (default), "horizontal", or "both" */
@@ -39,6 +38,9 @@ export class ScrollHost extends SingleChildHost {
39
38
  bg?: Color
40
39
  showScrollbar = true
41
40
  sticky = false
41
+
42
+ // Scroll is greedy by default - expands to fill available space
43
+ override greedy: boolean | number | undefined = 1
42
44
  onContentSize?: (width: number, height: number) => void
43
45
  onViewportSize?: (width: number, height: number) => void
44
46
  onEffectiveOffset?: (offset: number) => void
@@ -86,8 +88,12 @@ export class ScrollHost extends SingleChildHost {
86
88
  // Note: onContentSize callback is deferred to layout() to keep measure() pure
87
89
  }
88
90
 
89
- // Scroll container is greedy - takes all available space (after constraints)
90
- return this.constrainResult({ w: constrained.w, h: constrained.h })
91
+ // Report natural content size (clamped to constrained bounds)
92
+ // Greedy expansion happens in layout phase via layoutFlex
93
+ const naturalW = Math.min(this.contentWidth, constrained.w)
94
+ const naturalH = Math.min(this.contentHeight, constrained.h)
95
+
96
+ return this.constrainResult({ w: naturalW, h: naturalH })
91
97
  }
92
98
 
93
99
  override layout(rect: Rect): void {
@@ -175,9 +181,8 @@ export class ScrollHost extends SingleChildHost {
175
181
  if (!this.rect) return
176
182
  const { x, y, w, h } = this.rect
177
183
 
178
- // Fill background
179
- const rawBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
180
- const { value: bgValue, styleId: bgStyleId } = resolveBgStyle(palette, rawBg)
184
+ // Fill background (inherit from parent if not explicitly set)
185
+ const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
181
186
  if (bgValue !== undefined) {
182
187
  buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
183
188
  }
@@ -241,6 +246,10 @@ export class ScrollHost extends SingleChildHost {
241
246
 
242
247
  override updateProps(props: Record<string, unknown>): void {
243
248
  super.updateProps(props)
249
+ // Scroll is greedy by default unless explicitly set to false
250
+ if (!("greedy" in props)) {
251
+ this.greedy = 1
252
+ }
244
253
  if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
245
254
  if (props.offset !== undefined) this.offset = props.offset as number
246
255
  if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
@@ -13,15 +13,16 @@ export class SpacerHost extends BaseHost {
13
13
  minWidth = 0
14
14
  minHeight = 0
15
15
 
16
+ // Spacers are greedy by default
17
+ override greedy: boolean | number | undefined = 1
18
+
16
19
  constructor(props: SpacerProps, ctx: HostContext) {
17
- // Spacers have flexGrow=1 by default
18
- const propsWithDefaults = { flexGrow: 1, ...props }
19
- super("spacer", propsWithDefaults, ctx)
20
- this.updateProps(propsWithDefaults)
20
+ super("spacer", props, ctx)
21
+ this.updateProps(props)
21
22
  }
22
23
 
23
24
  measure(_maxW: number, _maxH: number): Size {
24
- // Spacers have no natural size, they expand via flexGrow
25
+ // Spacers have no natural size, they expand via greedy
25
26
  return { w: this.minWidth, h: this.minHeight }
26
27
  }
27
28
 
@@ -34,10 +35,11 @@ export class SpacerHost extends BaseHost {
34
35
  }
35
36
 
36
37
  override updateProps(props: Record<string, unknown>): void {
37
- // Keep spacer default flexGrow=1 unless explicitly provided
38
- const flexGrow = props.flexGrow as number | undefined
39
- const propsWithDefaults = flexGrow === undefined ? { ...props, flexGrow: 1 } : props
40
- super.updateProps(propsWithDefaults)
38
+ super.updateProps(props)
39
+ // Spacer is greedy by default unless explicitly set to false
40
+ if (!("greedy" in props)) {
41
+ this.greedy = 1
42
+ }
41
43
  if (props.minWidth !== undefined) this.minWidth = props.minWidth as number
42
44
  if (props.minHeight !== undefined) this.minHeight = props.minHeight as number
43
45
  }
package/src/hosts/text.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { displayWidth, type CellBuffer, type Palette, type Color } from "@effect-tui/core"
2
2
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
3
3
  import { BaseHost, getInheritedBg } from "./base.js"
4
- import { resolveBgStyle, styleIdFromProps } from "../utils/index.js"
4
+ import { resolveInheritedBgStyle, styleIdFromProps } from "../utils/index.js"
5
5
 
6
6
  export interface TextProps extends CommonProps {
7
7
  fg?: Color
@@ -114,8 +114,8 @@ export class TextHost extends BaseHost {
114
114
  if (!this.rect) return
115
115
 
116
116
  // If text has no bg, inherit from parent box for proper highlight rendering
117
- const inheritedBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
118
- const { value: bgValue, styleId: bgStyleId } = resolveBgStyle(palette, inheritedBg)
117
+ const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
118
+ const inheritedBg = this.bg ?? getInheritedBg(this.parent)
119
119
 
120
120
  const styleId = styleIdFromProps(palette, {
121
121
  fg: this.fg,
@@ -1,6 +1,7 @@
1
1
  import type { CellBuffer, Palette } from "@effect-tui/core"
2
2
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
3
3
  import { BaseHost } from "./base.js"
4
+ import { alignInRect, type HAlign, type VAlign } from "../utils/index.js"
4
5
 
5
6
  export interface ZStackProps extends CommonProps {
6
7
  alignment?: { h?: "leading" | "center" | "trailing"; v?: "top" | "center" | "bottom" }
@@ -8,8 +9,8 @@ export interface ZStackProps extends CommonProps {
8
9
 
9
10
  // Overlay children in the same rect, honoring alignment for each child.
10
11
  export class ZStackHost extends BaseHost {
11
- alignmentH: "leading" | "center" | "trailing" = "center"
12
- alignmentV: "top" | "center" | "bottom" = "center"
12
+ alignmentH: HAlign = "center"
13
+ alignmentV: VAlign = "center"
13
14
  private cachedSizes: Size[] = []
14
15
 
15
16
  constructor(props: ZStackProps, ctx: HostContext) {
@@ -47,15 +48,7 @@ export class ZStackHost extends BaseHost {
47
48
  for (let i = 0; i < this.children.length; i++) {
48
49
  const child = this.children[i]
49
50
  const size = this.cachedSizes[i] ?? child.measure(rect.w, rect.h)
50
-
51
- let x = rect.x
52
- let y = rect.y
53
-
54
- if (this.alignmentH === "center") x += Math.floor((rect.w - size.w) / 2)
55
- else if (this.alignmentH === "trailing") x += Math.max(0, rect.w - size.w)
56
-
57
- if (this.alignmentV === "center") y += Math.floor((rect.h - size.h) / 2)
58
- else if (this.alignmentV === "bottom") y += Math.max(0, rect.h - size.h)
51
+ const { x, y } = alignInRect(rect, size, this.alignmentH, this.alignmentV)
59
52
 
60
53
  child.layout({
61
54
  x,
@@ -68,13 +61,7 @@ export class ZStackHost extends BaseHost {
68
61
 
69
62
  render(buffer: CellBuffer, palette: Palette): void {
70
63
  for (const child of this.children) {
71
- if (child.rect) {
72
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
73
- child.render(buffer, palette)
74
- })
75
- } else {
76
- child.render(buffer, palette)
77
- }
64
+ this.renderChildWithClip(child, buffer, palette)
78
65
  }
79
66
  }
80
67
 
@@ -92,10 +92,15 @@ export interface HostContext {
92
92
  * With the constraint, it reports exactly 10, so spacer gets the rest.
93
93
  */
94
94
  export interface CommonProps {
95
- /** Flex grow factor - how much extra space this view takes (default: 0) */
96
- flexGrow?: number
97
- /** Flex shrink factor - how much this view shrinks when space is tight (default: 1) */
98
- flexShrink?: number
95
+ /**
96
+ * Greedy layout - element expands to fill remaining space.
97
+ * - `true` or `1`: takes 1 share of remaining space
98
+ * - number > 1: takes proportionally more (e.g., `greedy={2}` gets 2x space)
99
+ * - `false` or undefined: hugs content (default)
100
+ *
101
+ * Scroll and Spacer are greedy by default.
102
+ */
103
+ greedy?: boolean | number
99
104
 
100
105
  // ─────────────────────────────────────────────────────────────
101
106
  // Frame constraints (like SwiftUI's .frame modifier)
@@ -39,6 +39,10 @@ const Info = Rpc.make("Info", {
39
39
  pid: Schema.Number,
40
40
  width: Schema.Number,
41
41
  height: Schema.Number,
42
+ /** Entry file path (e.g., /Users/kit/code/my-app/src/tui.tsx) */
43
+ entryPath: Schema.optional(Schema.String),
44
+ /** Short name derived from entry path (e.g., my-app/src/tui.tsx) */
45
+ name: Schema.optional(Schema.String),
42
46
  }),
43
47
  })
44
48
 
@@ -10,7 +10,13 @@ export interface TuiSessionImpl {
10
10
  readonly dispatchKey: (key: KeyMsg) => void
11
11
  readonly dispatchPaste: (text: string) => void
12
12
  readonly dispatchResize: (width: number, height: number) => void
13
- readonly getInfo: () => { pid: number; width: number; height: number }
13
+ readonly getInfo: () => {
14
+ pid: number
15
+ width: number
16
+ height: number
17
+ entryPath?: string
18
+ name?: string
19
+ }
14
20
  }
15
21
 
16
22
  export class TuiSession extends Context.Tag("TuiSession")<
@@ -17,19 +17,55 @@ import type { TuiRenderer } from "../renderer-types.js"
17
17
  import type { TuiSessionImpl } from "./Router.js"
18
18
  import { makeServerLayer, getSocketPath } from "./Server.js"
19
19
 
20
+ export interface EnableRemoteOptions {
21
+ /** Custom socket path (defaults to /tmp/effect-tui-sessions/<pid>.sock) */
22
+ socketPath?: string
23
+ /** Entry file path for identification (e.g., import.meta.path) */
24
+ entryPath?: string
25
+ }
26
+
27
+ /**
28
+ * Derive a short name from the entry path.
29
+ * Uses the last directory + filename (e.g., "/Users/kit/code/my-app/src/tui.tsx" -> "my-app/src/tui.tsx")
30
+ */
31
+ function deriveSessionName(entryPath: string): string {
32
+ const parts = entryPath.split("/")
33
+ // Find the last directory that looks like a project name (not src, lib, etc)
34
+ const commonDirs = ["src", "lib", "dist", "build", "packages"]
35
+ let srcIdx = -1
36
+ for (let i = parts.length - 1; i >= 0; i--) {
37
+ if (commonDirs.includes(parts[i])) {
38
+ srcIdx = i
39
+ break
40
+ }
41
+ }
42
+ if (srcIdx > 0) {
43
+ // Include project name + relative path from there
44
+ return parts.slice(srcIdx - 1).join("/")
45
+ }
46
+ // Fallback: last 3 path segments
47
+ return parts.slice(-3).join("/")
48
+ }
49
+
20
50
  /**
21
51
  * Enable remote control for a TUI renderer.
22
52
  * Starts an RPC server on a Unix socket at /tmp/effect-tui-sessions/<pid>.sock
23
53
  *
24
54
  * @param renderer - The TUI renderer to control
25
- * @param socketPath - Optional custom socket path
55
+ * @param options - Socket path and entry path for session identification
26
56
  * @returns A cleanup function to stop the server
27
57
  */
28
58
  export function enableRemote(
29
59
  renderer: TuiRenderer,
30
- socketPath?: string,
60
+ options?: EnableRemoteOptions | string,
31
61
  ): () => void {
32
- const actualPath = socketPath ?? getSocketPath()
62
+ // Support old API: enableRemote(renderer, socketPath?)
63
+ const opts: EnableRemoteOptions =
64
+ typeof options === "string" ? { socketPath: options } : options ?? {}
65
+ const actualPath = opts.socketPath ?? getSocketPath()
66
+
67
+ // Derive session name from entry path
68
+ const name = opts.entryPath ? deriveSessionName(opts.entryPath) : undefined
33
69
 
34
70
  // Create session implementation from renderer
35
71
  const session: TuiSessionImpl = {
@@ -41,6 +77,8 @@ export function enableRemote(
41
77
  pid: process.pid,
42
78
  width: renderer.width,
43
79
  height: renderer.height,
80
+ entryPath: opts.entryPath,
81
+ name,
44
82
  }),
45
83
  }
46
84
 
@@ -7,6 +7,7 @@ export interface InputProcessorConfig {
7
7
  dispatchPaste: (text: string) => void
8
8
  flushSync: <T>(fn: () => T) => T
9
9
  onInputProcessed: () => void
10
+ onQuit?: () => void // Called instead of process.exit() for proper cleanup
10
11
  }
11
12
 
12
13
  /**
@@ -86,7 +87,11 @@ export class InputProcessor {
86
87
  // Default Ctrl+C handling - exit unless user called preventDefault()
87
88
  // Only match Ctrl+C (not Ctrl+Shift+C which should be available for copy)
88
89
  if (this.config.exitOnCtrlC && !wrapped.defaultPrevented && event.ctrl && !event.shift && event.text === "c") {
89
- process.exit(0)
90
+ if (this.config.onQuit) {
91
+ this.config.onQuit()
92
+ } else {
93
+ process.exit(0)
94
+ }
90
95
  }
91
96
  }
92
97
  }