@effect-tui/react 0.1.7 → 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.
- package/dist/src/hooks/index.d.ts +1 -0
- package/dist/src/hooks/index.d.ts.map +1 -1
- package/dist/src/hooks/index.js +1 -0
- package/dist/src/hooks/index.js.map +1 -1
- package/dist/src/hooks/use-quit.d.ts +21 -0
- package/dist/src/hooks/use-quit.d.ts.map +1 -0
- package/dist/src/hooks/use-quit.js +29 -0
- package/dist/src/hooks/use-quit.js.map +1 -0
- package/dist/src/hosts/base.d.ts +1 -2
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +42 -6
- package/dist/src/hosts/base.js.map +1 -1
- package/dist/src/hosts/flex-container.d.ts.map +1 -1
- package/dist/src/hosts/flex-container.js +4 -1
- package/dist/src/hosts/flex-container.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +1 -0
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +11 -2
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/spacer.d.ts +1 -0
- package/dist/src/hosts/spacer.d.ts.map +1 -1
- package/dist/src/hosts/spacer.js +10 -9
- package/dist/src/hosts/spacer.js.map +1 -1
- package/dist/src/reconciler/types.d.ts +9 -4
- package/dist/src/reconciler/types.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.d.ts +1 -0
- package/dist/src/renderer/input/InputProcessor.d.ts.map +1 -1
- package/dist/src/renderer/input/InputProcessor.js +6 -1
- package/dist/src/renderer/input/InputProcessor.js.map +1 -1
- package/dist/src/renderer/lifecycle/TerminalSetup.d.ts +1 -0
- package/dist/src/renderer/lifecycle/TerminalSetup.d.ts.map +1 -1
- package/dist/src/renderer/lifecycle/TerminalSetup.js +26 -17
- package/dist/src/renderer/lifecycle/TerminalSetup.js.map +1 -1
- package/dist/src/renderer.d.ts +2 -0
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +39 -7
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/test/render-tui.d.ts +5 -0
- package/dist/src/test/render-tui.d.ts.map +1 -1
- package/dist/src/test/render-tui.js +3 -0
- package/dist/src/test/render-tui.js.map +1 -1
- package/dist/src/utils/flex-layout.d.ts.map +1 -1
- package/dist/src/utils/flex-layout.js +20 -6
- package/dist/src/utils/flex-layout.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-quit.ts +33 -0
- package/src/hosts/base.ts +36 -6
- package/src/hosts/flex-container.ts +3 -1
- package/src/hosts/scroll.ts +13 -2
- package/src/hosts/spacer.ts +11 -9
- package/src/reconciler/types.ts +9 -4
- package/src/renderer/input/InputProcessor.ts +6 -1
- package/src/renderer/lifecycle/TerminalSetup.ts +28 -17
- package/src/renderer.ts +44 -6
- package/src/test/render-tui.ts +7 -0
- package/src/utils/flex-layout.ts +19 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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/hooks/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
@@ -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,
|
package/src/hosts/scroll.ts
CHANGED
|
@@ -38,6 +38,9 @@ export class ScrollHost extends SingleChildHost {
|
|
|
38
38
|
bg?: Color
|
|
39
39
|
showScrollbar = true
|
|
40
40
|
sticky = false
|
|
41
|
+
|
|
42
|
+
// Scroll is greedy by default - expands to fill available space
|
|
43
|
+
override greedy: boolean | number | undefined = 1
|
|
41
44
|
onContentSize?: (width: number, height: number) => void
|
|
42
45
|
onViewportSize?: (width: number, height: number) => void
|
|
43
46
|
onEffectiveOffset?: (offset: number) => void
|
|
@@ -85,8 +88,12 @@ export class ScrollHost extends SingleChildHost {
|
|
|
85
88
|
// Note: onContentSize callback is deferred to layout() to keep measure() pure
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
//
|
|
89
|
-
|
|
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 })
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
override layout(rect: Rect): void {
|
|
@@ -239,6 +246,10 @@ export class ScrollHost extends SingleChildHost {
|
|
|
239
246
|
|
|
240
247
|
override updateProps(props: Record<string, unknown>): void {
|
|
241
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
|
+
}
|
|
242
253
|
if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
|
|
243
254
|
if (props.offset !== undefined) this.offset = props.offset as number
|
|
244
255
|
if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
|
package/src/hosts/spacer.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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/reconciler/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
90
|
+
if (this.config.onQuit) {
|
|
91
|
+
this.config.onQuit()
|
|
92
|
+
} else {
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
}
|
|
@@ -70,36 +70,47 @@ export class TerminalSetup {
|
|
|
70
70
|
/**
|
|
71
71
|
* Restore terminal to normal state.
|
|
72
72
|
* Call this when stopping the renderer.
|
|
73
|
+
* Uses a single write call to ensure atomic output and reliable flushing.
|
|
73
74
|
*/
|
|
74
75
|
teardown(): void {
|
|
75
76
|
if (this.config.skipTerminalSetup) return
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.stdout.write(ANSI.reflow.enable)
|
|
82
|
-
this.stdout.write("\r\n")
|
|
78
|
+
// Disable raw mode FIRST - this is critical for proper terminal restoration
|
|
79
|
+
// Must happen before writing escape sequences so the terminal processes them correctly
|
|
80
|
+
if (this.stdin.isTTY && this.stdin.setRawMode) {
|
|
81
|
+
this.stdin.setRawMode(false)
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// Build all escape sequences into a single string for atomic write
|
|
85
|
+
// This ensures all sequences are flushed together before process exit
|
|
86
|
+
let output = ""
|
|
87
|
+
|
|
88
|
+
// Disable enhanced keyboard protocols first
|
|
89
|
+
if (this.config.enableKittyKeyboard !== false) {
|
|
90
|
+
output += ANSI.keyboard.disable
|
|
91
|
+
output += ANSI.modifyOtherKeys.disable
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
if (this.config.enableMouse) {
|
|
90
|
-
|
|
95
|
+
output += ANSI.mouse.disable
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.stdout.write(ANSI.keyboard.disable)
|
|
96
|
-
this.stdout.write(ANSI.modifyOtherKeys.disable)
|
|
98
|
+
if (this.config.enablePaste) {
|
|
99
|
+
output += ANSI.paste.disable
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
// Exit fullscreen or re-enable reflow
|
|
103
|
+
if (this.config.mode === "fullscreen") {
|
|
104
|
+
output += Terminal.exitFullscreen
|
|
105
|
+
} else {
|
|
106
|
+
output += ANSI.reflow.enable
|
|
107
|
+
output += "\r\n"
|
|
103
108
|
}
|
|
109
|
+
|
|
110
|
+
// Always show cursor at the end
|
|
111
|
+
output += Terminal.showCursor
|
|
112
|
+
|
|
113
|
+
// Single atomic write - more reliable for process exit scenarios
|
|
114
|
+
this.stdout.write(output)
|
|
104
115
|
}
|
|
105
116
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -71,6 +71,11 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
71
71
|
onInputProcessed: () => {
|
|
72
72
|
if (!manualMode) renderFrame()
|
|
73
73
|
},
|
|
74
|
+
onQuit: () => {
|
|
75
|
+
// Clean up terminal state before exiting
|
|
76
|
+
renderer.stop()
|
|
77
|
+
process.exit(0)
|
|
78
|
+
},
|
|
74
79
|
})
|
|
75
80
|
|
|
76
81
|
// The render frame logic
|
|
@@ -300,6 +305,27 @@ export function createRenderer(options?: RendererOptions): TuiRenderer {
|
|
|
300
305
|
}, frameMs)
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
// Process exit handlers - ensure terminal is restored on any exit
|
|
309
|
+
// These handlers are critical for proper cleanup when process.exit() is called
|
|
310
|
+
let cleanedUp = false
|
|
311
|
+
const cleanup = () => {
|
|
312
|
+
if (cleanedUp) return
|
|
313
|
+
cleanedUp = true
|
|
314
|
+
renderer.stop()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Handle normal process exit (synchronous - runs before exit completes)
|
|
318
|
+
const onExit = () => cleanup()
|
|
319
|
+
process.on("exit", onExit)
|
|
320
|
+
|
|
321
|
+
// Handle SIGINT (Ctrl+C from shell, not from TUI input) and SIGTERM
|
|
322
|
+
const onSignal = () => {
|
|
323
|
+
cleanup()
|
|
324
|
+
process.exit(0)
|
|
325
|
+
}
|
|
326
|
+
process.on("SIGINT", onSignal)
|
|
327
|
+
process.on("SIGTERM", onSignal)
|
|
328
|
+
|
|
303
329
|
;(renderer as TuiRendererInternal)._container = null
|
|
304
330
|
return renderer
|
|
305
331
|
}
|
|
@@ -365,6 +391,8 @@ export interface RenderInstance {
|
|
|
365
391
|
rerender(element: ReactNode): void
|
|
366
392
|
unmount(): void
|
|
367
393
|
waitUntilExit(): Promise<void>
|
|
394
|
+
/** Cleanly exit the application, restoring terminal state before process.exit() */
|
|
395
|
+
quit(code?: number): void
|
|
368
396
|
}
|
|
369
397
|
|
|
370
398
|
export function render(element: ReactNode, options?: RendererOptions): RenderInstance {
|
|
@@ -383,18 +411,20 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
383
411
|
}
|
|
384
412
|
})
|
|
385
413
|
|
|
414
|
+
// Resolve the exit promise on process exit
|
|
415
|
+
// Note: Terminal cleanup is handled by createRenderer's exit handlers
|
|
386
416
|
const onExit = () => {
|
|
387
|
-
|
|
388
|
-
renderer.stop()
|
|
389
|
-
resolveExit?.()
|
|
390
|
-
}
|
|
417
|
+
resolveExit?.()
|
|
391
418
|
}
|
|
392
419
|
process.once("exit", onExit)
|
|
393
420
|
|
|
394
421
|
const unmount = () => {
|
|
395
422
|
process.off("exit", onExit)
|
|
396
|
-
|
|
397
|
-
|
|
423
|
+
if (!resolved) {
|
|
424
|
+
resolved = true
|
|
425
|
+
renderer.stop()
|
|
426
|
+
resolveExit?.()
|
|
427
|
+
}
|
|
398
428
|
}
|
|
399
429
|
|
|
400
430
|
const rerender = (next: ReactNode) => {
|
|
@@ -402,11 +432,19 @@ export function render(element: ReactNode, options?: RendererOptions): RenderIns
|
|
|
402
432
|
root.render(next)
|
|
403
433
|
}
|
|
404
434
|
|
|
435
|
+
// Clean quit function - renderer.stop() + process.exit()
|
|
436
|
+
// The createRenderer exit handler will also run, but it's idempotent
|
|
437
|
+
const quit = (code = 0) => {
|
|
438
|
+
renderer.stop()
|
|
439
|
+
process.exit(code)
|
|
440
|
+
}
|
|
441
|
+
|
|
405
442
|
return {
|
|
406
443
|
renderer,
|
|
407
444
|
root,
|
|
408
445
|
rerender,
|
|
409
446
|
unmount,
|
|
410
447
|
waitUntilExit: () => exitPromise,
|
|
448
|
+
quit,
|
|
411
449
|
}
|
|
412
450
|
}
|
package/src/test/render-tui.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { MockStdout, MockStdin, stripAnsi, getVisibleLines } from "./mock-stream
|
|
|
7
7
|
export interface RenderTUIOptions {
|
|
8
8
|
width?: number
|
|
9
9
|
height?: number
|
|
10
|
+
mode?: "fullscreen" | "inline"
|
|
11
|
+
diff?: boolean
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface RenderTUIResult {
|
|
@@ -26,6 +28,8 @@ export interface RenderTUIResult {
|
|
|
26
28
|
stdout: MockStdout
|
|
27
29
|
/** Access mock stdin for advanced input simulation */
|
|
28
30
|
stdin: MockStdin
|
|
31
|
+
/** Access renderer for advanced assertions */
|
|
32
|
+
renderer: ReturnType<typeof createRenderer>
|
|
29
33
|
/** Resize the terminal */
|
|
30
34
|
resize(width: number, height: number): void
|
|
31
35
|
}
|
|
@@ -60,6 +64,8 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
|
|
|
60
64
|
stdin: stdin as any,
|
|
61
65
|
manualMode: true,
|
|
62
66
|
skipTerminalSetup: true,
|
|
67
|
+
mode: options?.mode,
|
|
68
|
+
diff: options?.diff,
|
|
63
69
|
})
|
|
64
70
|
|
|
65
71
|
const root = createRoot(renderer)
|
|
@@ -106,6 +112,7 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
|
|
|
106
112
|
|
|
107
113
|
stdout,
|
|
108
114
|
stdin,
|
|
115
|
+
renderer,
|
|
109
116
|
|
|
110
117
|
resize(w: number, h: number) {
|
|
111
118
|
stdout.resize(w, h)
|
package/src/utils/flex-layout.ts
CHANGED
|
@@ -53,6 +53,19 @@ export function measureFlex(
|
|
|
53
53
|
return { sizes, totalSize }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Get the greedy weight for a child.
|
|
58
|
+
* - undefined/false = 0 (not greedy, hugs content)
|
|
59
|
+
* - true = 1
|
|
60
|
+
* - number = that weight
|
|
61
|
+
*/
|
|
62
|
+
function getGreedyWeight(child: HostInstance): number {
|
|
63
|
+
const greedy = (child as BaseHost).greedy
|
|
64
|
+
if (greedy === undefined || greedy === false) return 0
|
|
65
|
+
if (greedy === true) return 1
|
|
66
|
+
return greedy
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
/**
|
|
57
70
|
* Layout children along a flex axis using cached sizes.
|
|
58
71
|
*/
|
|
@@ -67,17 +80,17 @@ export function layoutFlex(
|
|
|
67
80
|
): void {
|
|
68
81
|
// Calculate totals
|
|
69
82
|
let totalNaturalMain = 0
|
|
70
|
-
let
|
|
83
|
+
let totalGreedyWeight = 0
|
|
71
84
|
const totalSpacing = Math.max(0, (children.length - 1) * spacing)
|
|
72
85
|
|
|
73
86
|
for (let i = 0; i < children.length; i++) {
|
|
74
87
|
const child = children[i]
|
|
75
88
|
const size = cachedSizes[i] ?? child.measure(rect.w, rect.h)
|
|
76
89
|
totalNaturalMain += mainSize(axis, size)
|
|
77
|
-
|
|
90
|
+
totalGreedyWeight += getGreedyWeight(child)
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
// Calculate extra space to distribute
|
|
93
|
+
// Calculate extra space to distribute to greedy children
|
|
81
94
|
const availableMain = mainDim(axis, rect)
|
|
82
95
|
const extraSpace = Math.max(0, availableMain - totalNaturalMain - totalSpacing)
|
|
83
96
|
|
|
@@ -89,10 +102,10 @@ export function layoutFlex(
|
|
|
89
102
|
for (let i = 0; i < children.length; i++) {
|
|
90
103
|
const child = children[i]
|
|
91
104
|
const size = cachedSizes[i] ?? { w: 0, h: 0 }
|
|
92
|
-
const
|
|
93
|
-
const
|
|
105
|
+
const greedyWeight = getGreedyWeight(child)
|
|
106
|
+
const greedyExtra = totalGreedyWeight > 0 ? (extraSpace * greedyWeight) / totalGreedyWeight : 0
|
|
94
107
|
|
|
95
|
-
const childMainDim = mainSize(axis, size) +
|
|
108
|
+
const childMainDim = mainSize(axis, size) + greedyExtra
|
|
96
109
|
const childCrossDim = crossSize(axis, size)
|
|
97
110
|
|
|
98
111
|
// Calculate cross position based on alignment
|