@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/dist/src/components/TextInput.js +1 -1
- package/dist/src/components/TextInput.js.map +1 -1
- package/dist/src/console/ConsolePopover.d.ts.map +1 -1
- package/dist/src/console/ConsolePopover.js +6 -6
- package/dist/src/console/ConsolePopover.js.map +1 -1
- package/dist/src/exit.d.ts +7 -0
- package/dist/src/exit.d.ts.map +1 -0
- package/dist/src/exit.js +9 -0
- package/dist/src/exit.js.map +1 -0
- package/dist/src/hooks/use-quit.d.ts.map +1 -1
- package/dist/src/hooks/use-quit.js +2 -1
- package/dist/src/hooks/use-quit.js.map +1 -1
- package/dist/src/hosts/canvas.d.ts +3 -0
- package/dist/src/hosts/canvas.d.ts.map +1 -1
- package/dist/src/hosts/canvas.js +9 -1
- package/dist/src/hosts/canvas.js.map +1 -1
- package/dist/src/renderer-types.d.ts +6 -0
- package/dist/src/renderer-types.d.ts.map +1 -1
- package/dist/src/renderer.d.ts.map +1 -1
- package/dist/src/renderer.js +35 -10
- package/dist/src/renderer.js.map +1 -1
- package/dist/src/utils/flex-layout.d.ts +14 -0
- package/dist/src/utils/flex-layout.d.ts.map +1 -1
- package/dist/src/utils/flex-layout.js +78 -23
- package/dist/src/utils/flex-layout.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/TextInput.tsx +1 -1
- package/src/console/ConsolePopover.tsx +85 -93
- package/src/exit.ts +8 -0
- package/src/hooks/use-quit.ts +2 -1
- package/src/hosts/canvas.ts +11 -0
- package/src/renderer-types.ts +6 -0
- package/src/renderer.ts +35 -9
- package/src/utils/flex-layout.ts +81 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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",
|
|
@@ -300,107 +300,99 @@ export function ConsolePopover({
|
|
|
300
300
|
[contentStartY, scrollState.offset, displayLines.length],
|
|
301
301
|
)
|
|
302
302
|
|
|
303
|
-
// Keyboard handler
|
|
304
|
-
useKeyboard(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
package/src/hooks/use-quit.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
+
requestExit(code)
|
|
30
31
|
},
|
|
31
32
|
[renderer],
|
|
32
33
|
)
|
package/src/hosts/canvas.ts
CHANGED
|
@@ -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
|
}
|
package/src/renderer-types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
339
|
+
onExit = () => cleanup()
|
|
318
340
|
process.on("exit", onExit)
|
|
319
341
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
464
|
+
requestExit(code)
|
|
439
465
|
}
|
|
440
466
|
|
|
441
467
|
return {
|
package/src/utils/flex-layout.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
94
|
-
const
|
|
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
|
-
|
|
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
|