@effect-tui/react 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/jsx-dev-runtime.d.ts +3 -0
- package/dist/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-dev-runtime.js.map +1 -0
- package/dist/jsx-runtime.d.ts +47 -0
- package/dist/jsx-runtime.d.ts.map +1 -0
- package/dist/jsx-runtime.js +6 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/src/codeblock.d.ts +9 -0
- package/dist/src/codeblock.d.ts.map +1 -0
- package/dist/src/codeblock.js +24 -0
- package/dist/src/codeblock.js.map +1 -0
- package/dist/src/constants.d.ts +3 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +3 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/debug/DiagnosticsPanel.d.ts +7 -0
- package/dist/src/debug/DiagnosticsPanel.d.ts.map +1 -0
- package/dist/src/debug/DiagnosticsPanel.js +13 -0
- package/dist/src/debug/DiagnosticsPanel.js.map +1 -0
- package/dist/src/highlight.d.ts +20 -0
- package/dist/src/highlight.d.ts.map +1 -0
- package/dist/src/highlight.js +51 -0
- package/dist/src/highlight.js.map +1 -0
- package/dist/src/hooks/index.d.ts +4 -0
- package/dist/src/hooks/index.d.ts.map +1 -0
- package/dist/src/hooks/index.js +3 -0
- package/dist/src/hooks/index.js.map +1 -0
- package/dist/src/hooks/use-keyboard.d.ts +18 -0
- package/dist/src/hooks/use-keyboard.d.ts.map +1 -0
- package/dist/src/hooks/use-keyboard.js +26 -0
- package/dist/src/hooks/use-keyboard.js.map +1 -0
- package/dist/src/hooks/use-paste.d.ts +5 -0
- package/dist/src/hooks/use-paste.d.ts.map +1 -0
- package/dist/src/hooks/use-paste.js +14 -0
- package/dist/src/hooks/use-paste.js.map +1 -0
- package/dist/src/hooks/useFrameStats.d.ts +7 -0
- package/dist/src/hooks/useFrameStats.d.ts.map +1 -0
- package/dist/src/hooks/useFrameStats.js +28 -0
- package/dist/src/hooks/useFrameStats.js.map +1 -0
- package/dist/src/hosts/base.d.ts +22 -0
- package/dist/src/hosts/base.d.ts.map +1 -0
- package/dist/src/hosts/base.js +53 -0
- package/dist/src/hosts/base.js.map +1 -0
- package/dist/src/hosts/box.d.ts +26 -0
- package/dist/src/hosts/box.d.ts.map +1 -0
- package/dist/src/hosts/box.js +84 -0
- package/dist/src/hosts/box.js.map +1 -0
- package/dist/src/hosts/canvas.d.ts +48 -0
- package/dist/src/hosts/canvas.d.ts.map +1 -0
- package/dist/src/hosts/canvas.js +109 -0
- package/dist/src/hosts/canvas.js.map +1 -0
- package/dist/src/hosts/codeblock.d.ts +32 -0
- package/dist/src/hosts/codeblock.d.ts.map +1 -0
- package/dist/src/hosts/codeblock.js +118 -0
- package/dist/src/hosts/codeblock.js.map +1 -0
- package/dist/src/hosts/hstack.d.ts +18 -0
- package/dist/src/hosts/hstack.d.ts.map +1 -0
- package/dist/src/hosts/hstack.js +45 -0
- package/dist/src/hosts/hstack.js.map +1 -0
- package/dist/src/hosts/index.d.ts +16 -0
- package/dist/src/hosts/index.d.ts.map +1 -0
- package/dist/src/hosts/index.js +40 -0
- package/dist/src/hosts/index.js.map +1 -0
- package/dist/src/hosts/spacer.d.ts +19 -0
- package/dist/src/hosts/spacer.d.ts.map +1 -0
- package/dist/src/hosts/spacer.js +28 -0
- package/dist/src/hosts/spacer.js.map +1 -0
- package/dist/src/hosts/text.d.ts +43 -0
- package/dist/src/hosts/text.d.ts.map +1 -0
- package/dist/src/hosts/text.js +148 -0
- package/dist/src/hosts/text.js.map +1 -0
- package/dist/src/hosts/vstack.d.ts +18 -0
- package/dist/src/hosts/vstack.d.ts.map +1 -0
- package/dist/src/hosts/vstack.js +45 -0
- package/dist/src/hosts/vstack.js.map +1 -0
- package/dist/src/hosts/zstack.d.ts +20 -0
- package/dist/src/hosts/zstack.d.ts.map +1 -0
- package/dist/src/hosts/zstack.js +65 -0
- package/dist/src/hosts/zstack.js.map +1 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/inline/index.d.ts +32 -0
- package/dist/src/inline/index.d.ts.map +1 -0
- package/dist/src/inline/index.js +111 -0
- package/dist/src/inline/index.js.map +1 -0
- package/dist/src/jsx.d.ts +2 -0
- package/dist/src/jsx.d.ts.map +1 -0
- package/dist/src/jsx.js +4 -0
- package/dist/src/jsx.js.map +1 -0
- package/dist/src/motion/color-motion-value.d.ts +32 -0
- package/dist/src/motion/color-motion-value.d.ts.map +1 -0
- package/dist/src/motion/color-motion-value.js +80 -0
- package/dist/src/motion/color-motion-value.js.map +1 -0
- package/dist/src/motion/color.d.ts +30 -0
- package/dist/src/motion/color.d.ts.map +1 -0
- package/dist/src/motion/color.js +172 -0
- package/dist/src/motion/color.js.map +1 -0
- package/dist/src/motion/color.test.d.ts +2 -0
- package/dist/src/motion/color.test.d.ts.map +1 -0
- package/dist/src/motion/color.test.js +97 -0
- package/dist/src/motion/color.test.js.map +1 -0
- package/dist/src/motion/event-emitter.d.ts +18 -0
- package/dist/src/motion/event-emitter.d.ts.map +1 -0
- package/dist/src/motion/event-emitter.js +30 -0
- package/dist/src/motion/event-emitter.js.map +1 -0
- package/dist/src/motion/frame.d.ts +9 -0
- package/dist/src/motion/frame.d.ts.map +1 -0
- package/dist/src/motion/frame.js +51 -0
- package/dist/src/motion/frame.js.map +1 -0
- package/dist/src/motion/hooks.d.ts +75 -0
- package/dist/src/motion/hooks.d.ts.map +1 -0
- package/dist/src/motion/hooks.js +190 -0
- package/dist/src/motion/hooks.js.map +1 -0
- package/dist/src/motion/index.d.ts +4 -0
- package/dist/src/motion/index.d.ts.map +1 -0
- package/dist/src/motion/index.js +7 -0
- package/dist/src/motion/index.js.map +1 -0
- package/dist/src/motion/motion-value.d.ts +40 -0
- package/dist/src/motion/motion-value.d.ts.map +1 -0
- package/dist/src/motion/motion-value.js +109 -0
- package/dist/src/motion/motion-value.js.map +1 -0
- package/dist/src/motion/motion-value.test.d.ts +2 -0
- package/dist/src/motion/motion-value.test.d.ts.map +1 -0
- package/dist/src/motion/motion-value.test.js +177 -0
- package/dist/src/motion/motion-value.test.js.map +1 -0
- package/dist/src/motion/spring-math.d.ts +28 -0
- package/dist/src/motion/spring-math.d.ts.map +1 -0
- package/dist/src/motion/spring-math.js +81 -0
- package/dist/src/motion/spring-math.js.map +1 -0
- package/dist/src/motion/types.d.ts +25 -0
- package/dist/src/motion/types.d.ts.map +1 -0
- package/dist/src/motion/types.js +13 -0
- package/dist/src/motion/types.js.map +1 -0
- package/dist/src/output.d.ts +47 -0
- package/dist/src/output.d.ts.map +1 -0
- package/dist/src/output.js +125 -0
- package/dist/src/output.js.map +1 -0
- package/dist/src/profiler.d.ts +6 -0
- package/dist/src/profiler.d.ts.map +1 -0
- package/dist/src/profiler.js +73 -0
- package/dist/src/profiler.js.map +1 -0
- package/dist/src/reconciler/host-config.d.ts +16 -0
- package/dist/src/reconciler/host-config.d.ts.map +1 -0
- package/dist/src/reconciler/host-config.js +174 -0
- package/dist/src/reconciler/host-config.js.map +1 -0
- package/dist/src/reconciler/types.d.ts +52 -0
- package/dist/src/reconciler/types.d.ts.map +1 -0
- package/dist/src/reconciler/types.js +2 -0
- package/dist/src/reconciler/types.js.map +1 -0
- package/dist/src/renderer.d.ts +101 -0
- package/dist/src/renderer.d.ts.map +1 -0
- package/dist/src/renderer.js +509 -0
- package/dist/src/renderer.js.map +1 -0
- package/dist/src/terminal.d.ts +37 -0
- package/dist/src/terminal.d.ts.map +1 -0
- package/dist/src/terminal.js +65 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/test/index.d.ts +3 -0
- package/dist/src/test/index.d.ts.map +1 -0
- package/dist/src/test/index.js +3 -0
- package/dist/src/test/index.js.map +1 -0
- package/dist/src/test/mock-streams.d.ts +44 -0
- package/dist/src/test/mock-streams.d.ts.map +1 -0
- package/dist/src/test/mock-streams.js +136 -0
- package/dist/src/test/mock-streams.js.map +1 -0
- package/dist/src/test/render-tui.d.ts +47 -0
- package/dist/src/test/render-tui.d.ts.map +1 -0
- package/dist/src/test/render-tui.js +76 -0
- package/dist/src/test/render-tui.js.map +1 -0
- package/dist/src/trace/SpanTree.d.ts +10 -0
- package/dist/src/trace/SpanTree.d.ts.map +1 -0
- package/dist/src/trace/SpanTree.js +104 -0
- package/dist/src/trace/SpanTree.js.map +1 -0
- package/dist/src/trace/index.d.ts +30 -0
- package/dist/src/trace/index.d.ts.map +1 -0
- package/dist/src/trace/index.js +142 -0
- package/dist/src/trace/index.js.map +1 -0
- package/dist/src/trace/location.d.ts +9 -0
- package/dist/src/trace/location.d.ts.map +1 -0
- package/dist/src/trace/location.js +88 -0
- package/dist/src/trace/location.js.map +1 -0
- package/dist/src/trace/span-processor.d.ts +16 -0
- package/dist/src/trace/span-processor.d.ts.map +1 -0
- package/dist/src/trace/span-processor.js +54 -0
- package/dist/src/trace/span-processor.js.map +1 -0
- package/dist/src/trace/span-state.d.ts +79 -0
- package/dist/src/trace/span-state.d.ts.map +1 -0
- package/dist/src/trace/span-state.js +229 -0
- package/dist/src/trace/span-state.js.map +1 -0
- package/dist/src/trace/tui-logger.d.ts +8 -0
- package/dist/src/trace/tui-logger.d.ts.map +1 -0
- package/dist/src/trace/tui-logger.js +70 -0
- package/dist/src/trace/tui-logger.js.map +1 -0
- package/dist/src/utils/border.d.ts +31 -0
- package/dist/src/utils/border.d.ts.map +1 -0
- package/dist/src/utils/border.js +81 -0
- package/dist/src/utils/border.js.map +1 -0
- package/dist/src/utils/flex-layout.d.ts +20 -0
- package/dist/src/utils/flex-layout.d.ts.map +1 -0
- package/dist/src/utils/flex-layout.js +85 -0
- package/dist/src/utils/flex-layout.js.map +1 -0
- package/dist/src/utils/index.d.ts +5 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +5 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/padding.d.ts +26 -0
- package/dist/src/utils/padding.d.ts.map +1 -0
- package/dist/src/utils/padding.js +34 -0
- package/dist/src/utils/padding.js.map +1 -0
- package/dist/src/utils/styles.d.ts +13 -0
- package/dist/src/utils/styles.d.ts.map +1 -0
- package/dist/src/utils/styles.js +5 -0
- package/dist/src/utils/styles.js.map +1 -0
- package/dist/src/visualize/index.d.ts +50 -0
- package/dist/src/visualize/index.d.ts.map +1 -0
- package/dist/src/visualize/index.js +194 -0
- package/dist/src/visualize/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +94 -0
- package/src/codeblock.tsx +47 -0
- package/src/constants.ts +2 -0
- package/src/debug/DiagnosticsPanel.tsx +38 -0
- package/src/highlight.ts +76 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-keyboard.ts +37 -0
- package/src/hooks/use-paste.ts +14 -0
- package/src/hooks/useFrameStats.ts +32 -0
- package/src/hosts/base.ts +65 -0
- package/src/hosts/box.ts +105 -0
- package/src/hosts/canvas.ts +155 -0
- package/src/hosts/codeblock.ts +145 -0
- package/src/hosts/hstack.ts +64 -0
- package/src/hosts/index.ts +45 -0
- package/src/hosts/spacer.ts +40 -0
- package/src/hosts/text.ts +175 -0
- package/src/hosts/vstack.ts +64 -0
- package/src/hosts/zstack.ts +77 -0
- package/src/index.ts +62 -0
- package/src/inline/index.tsx +181 -0
- package/src/jsx.ts +3 -0
- package/src/motion/color-motion-value.ts +90 -0
- package/src/motion/color.test.ts +115 -0
- package/src/motion/color.ts +191 -0
- package/src/motion/event-emitter.ts +35 -0
- package/src/motion/frame.ts +59 -0
- package/src/motion/hooks.ts +237 -0
- package/src/motion/index.ts +17 -0
- package/src/motion/motion-value.test.ts +222 -0
- package/src/motion/motion-value.ts +140 -0
- package/src/motion/spring-math.ts +114 -0
- package/src/motion/types.ts +34 -0
- package/src/output.ts +156 -0
- package/src/profiler.ts +88 -0
- package/src/reconciler/host-config.ts +277 -0
- package/src/reconciler/types.ts +66 -0
- package/src/renderer.ts +661 -0
- package/src/terminal.ts +67 -0
- package/src/test/index.ts +8 -0
- package/src/test/mock-streams.ts +149 -0
- package/src/test/render-tui.ts +118 -0
- package/src/trace/SpanTree.tsx +195 -0
- package/src/trace/index.tsx +205 -0
- package/src/trace/location.ts +90 -0
- package/src/trace/span-processor.ts +65 -0
- package/src/trace/span-state.ts +286 -0
- package/src/trace/tui-logger.ts +72 -0
- package/src/utils/border.ts +108 -0
- package/src/utils/flex-layout.ts +125 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/padding.ts +45 -0
- package/src/utils/styles.ts +14 -0
- package/src/visualize/index.tsx +305 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spring physics calculations using analytical solutions.
|
|
3
|
+
* Matches framer-motion's formulas.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SpringState {
|
|
7
|
+
value: number
|
|
8
|
+
velocity: number
|
|
9
|
+
done: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type SpringResolver = (t: number) => SpringState
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a spring resolver function using analytical solution.
|
|
16
|
+
* Returns a function that computes position/velocity at any time t.
|
|
17
|
+
*/
|
|
18
|
+
export function createSpringResolver(
|
|
19
|
+
from: number,
|
|
20
|
+
to: number,
|
|
21
|
+
initialVelocity: number,
|
|
22
|
+
stiffness: number,
|
|
23
|
+
damping: number,
|
|
24
|
+
mass: number,
|
|
25
|
+
restSpeed = 0.01,
|
|
26
|
+
restDelta = 0.01,
|
|
27
|
+
): SpringResolver {
|
|
28
|
+
// Use same convention as framer-motion: initialDelta = from - to
|
|
29
|
+
const initialDelta = from - to
|
|
30
|
+
const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass))
|
|
31
|
+
const angularFreq = Math.sqrt(stiffness / mass)
|
|
32
|
+
const gamma = dampingRatio * angularFreq
|
|
33
|
+
|
|
34
|
+
if (dampingRatio < 1) {
|
|
35
|
+
// Underdamped (oscillates)
|
|
36
|
+
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio)
|
|
37
|
+
// Coefficients matching framer-motion's formula
|
|
38
|
+
const A = initialDelta
|
|
39
|
+
const B = (initialVelocity + gamma * initialDelta) / dampedFreq
|
|
40
|
+
|
|
41
|
+
return (t: number): SpringState => {
|
|
42
|
+
const envelope = Math.exp(-gamma * t)
|
|
43
|
+
const cos = Math.cos(dampedFreq * t)
|
|
44
|
+
const sin = Math.sin(dampedFreq * t)
|
|
45
|
+
|
|
46
|
+
// Position: to + envelope * (A*cos + B*sin)
|
|
47
|
+
const value = to + envelope * (A * cos + B * sin)
|
|
48
|
+
|
|
49
|
+
// Velocity: derivative of position
|
|
50
|
+
const velocity = envelope * ((B * dampedFreq - gamma * A) * cos + (-A * dampedFreq - gamma * B) * sin)
|
|
51
|
+
|
|
52
|
+
const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
|
|
53
|
+
|
|
54
|
+
return { value, velocity, done }
|
|
55
|
+
}
|
|
56
|
+
} else if (dampingRatio === 1) {
|
|
57
|
+
// Critically damped (fastest without oscillation)
|
|
58
|
+
const A = initialDelta
|
|
59
|
+
const B = initialVelocity + angularFreq * initialDelta
|
|
60
|
+
|
|
61
|
+
return (t: number): SpringState => {
|
|
62
|
+
const envelope = Math.exp(-angularFreq * t)
|
|
63
|
+
const value = to + envelope * (A + B * t)
|
|
64
|
+
const velocity = envelope * (B - angularFreq * A - angularFreq * B * t)
|
|
65
|
+
|
|
66
|
+
const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
|
|
67
|
+
|
|
68
|
+
return { value, velocity, done }
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Overdamped (slow, no oscillation)
|
|
72
|
+
const s = Math.sqrt(dampingRatio * dampingRatio - 1)
|
|
73
|
+
const r1 = -angularFreq * (dampingRatio - s)
|
|
74
|
+
const r2 = -angularFreq * (dampingRatio + s)
|
|
75
|
+
// Solve: A + B = initialDelta, A*r1 + B*r2 = initialVelocity
|
|
76
|
+
const A = (initialVelocity - r2 * initialDelta) / (r1 - r2)
|
|
77
|
+
const B = initialDelta - A
|
|
78
|
+
|
|
79
|
+
return (t: number): SpringState => {
|
|
80
|
+
const e1 = Math.exp(r1 * t)
|
|
81
|
+
const e2 = Math.exp(r2 * t)
|
|
82
|
+
const value = to + A * e1 + B * e2
|
|
83
|
+
const velocity = A * r1 * e1 + B * r2 * e2
|
|
84
|
+
|
|
85
|
+
const done = Math.abs(velocity) < restSpeed && Math.abs(to - value) < restDelta
|
|
86
|
+
|
|
87
|
+
return { value, velocity, done }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert visualDuration/bounce to stiffness/damping.
|
|
94
|
+
* Uses Motion's formula from motion-dom.
|
|
95
|
+
*/
|
|
96
|
+
export function springFromVisualDuration(
|
|
97
|
+
visualDuration: number,
|
|
98
|
+
bounce: number,
|
|
99
|
+
): { stiffness: number; damping: number } {
|
|
100
|
+
const root = (2 * Math.PI) / (visualDuration * 1.2)
|
|
101
|
+
const stiffness = root * root
|
|
102
|
+
// Clamp bounce to [0.05, 1], then invert: 0 bounce = damping ratio 1, 1 bounce = 0.05
|
|
103
|
+
const clampedBounce = Math.max(0.05, Math.min(1, 1 - bounce))
|
|
104
|
+
const damping = 2 * clampedBounce * Math.sqrt(stiffness)
|
|
105
|
+
|
|
106
|
+
return { stiffness, damping }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a spring has settled.
|
|
111
|
+
*/
|
|
112
|
+
export function isSpringSettled(velocity: number, delta: number, restSpeed = 0.01, restDelta = 0.01): boolean {
|
|
113
|
+
return Math.abs(velocity) < restSpeed && Math.abs(delta) < restDelta
|
|
114
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the motion system.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SpringOptions {
|
|
6
|
+
/** Stiffness of the spring. Higher = snappier. Default: 100 */
|
|
7
|
+
stiffness?: number
|
|
8
|
+
/** Damping of the spring. Higher = less oscillation. Default: 10 */
|
|
9
|
+
damping?: number
|
|
10
|
+
/** Mass of the spring. Higher = more inertia. Default: 1 */
|
|
11
|
+
mass?: number
|
|
12
|
+
/**
|
|
13
|
+
* Visual duration in seconds. This is the perceptual duration of the animation.
|
|
14
|
+
* The spring will feel like it completes in this time, though it may technically
|
|
15
|
+
* continue settling. Overrides stiffness/damping when provided.
|
|
16
|
+
*/
|
|
17
|
+
visualDuration?: number
|
|
18
|
+
/** Bounce amount 0-1 when using visualDuration. 0 = no bounce, 1 = full bounce. Default: 0 */
|
|
19
|
+
bounce?: number
|
|
20
|
+
/** Velocity threshold to consider settled. Default: 0.01 */
|
|
21
|
+
restSpeed?: number
|
|
22
|
+
/** Position threshold to consider settled. Default: 0.01 */
|
|
23
|
+
restDelta?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SPRING_OPTIONS: Required<SpringOptions> = {
|
|
27
|
+
stiffness: 100,
|
|
28
|
+
damping: 10,
|
|
29
|
+
mass: 1,
|
|
30
|
+
visualDuration: 0,
|
|
31
|
+
bounce: 0,
|
|
32
|
+
restSpeed: 0.01,
|
|
33
|
+
restDelta: 0.01,
|
|
34
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output helpers for rendering CellBuffer to ANSI strings.
|
|
3
|
+
* Extracts common logic from renderer.ts rendering paths.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CellBuffer, Palette } from "@effect-tui/core"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emit a row of cells as an ANSI string with run-length SGR encoding.
|
|
10
|
+
* Handles wide characters (cellWidth=0 continuations) and style changes.
|
|
11
|
+
*
|
|
12
|
+
* @param buffer - The cell buffer to read from
|
|
13
|
+
* @param palette - Palette for SGR code generation
|
|
14
|
+
* @param y - Row index
|
|
15
|
+
* @param width - Terminal width
|
|
16
|
+
* @param startX - Start column (default 0)
|
|
17
|
+
* @param endX - End column exclusive (default width)
|
|
18
|
+
* @returns ANSI string for the row (no cursor positioning, no trailing reset)
|
|
19
|
+
*/
|
|
20
|
+
export function emitRow(
|
|
21
|
+
buffer: CellBuffer,
|
|
22
|
+
palette: Palette,
|
|
23
|
+
y: number,
|
|
24
|
+
width: number,
|
|
25
|
+
startX = 0,
|
|
26
|
+
endX = width,
|
|
27
|
+
): { output: string; lastStyle: number } {
|
|
28
|
+
const row = y * width
|
|
29
|
+
let output = ""
|
|
30
|
+
let visualCol = startX
|
|
31
|
+
let currentStyle = -1
|
|
32
|
+
|
|
33
|
+
for (let x = startX; x < endX && visualCol < width; x++) {
|
|
34
|
+
const idx = row + x
|
|
35
|
+
const glyph = buffer.g[idx]
|
|
36
|
+
const styleId = buffer.s[idx]
|
|
37
|
+
const cellWidth = buffer.cw[idx] || 1
|
|
38
|
+
|
|
39
|
+
// Skip continuation cells (wide char second half)
|
|
40
|
+
if (cellWidth === 0) continue
|
|
41
|
+
|
|
42
|
+
// Stop if this char would overflow
|
|
43
|
+
if (visualCol + cellWidth > width) break
|
|
44
|
+
|
|
45
|
+
// Emit SGR only when style changes (run-length encoding)
|
|
46
|
+
if (styleId !== currentStyle) {
|
|
47
|
+
output += palette.sgr(styleId)
|
|
48
|
+
currentStyle = styleId
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
output += glyph === 32 ? " " : String.fromCodePoint(glyph)
|
|
52
|
+
visualCol += cellWidth
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { output, lastStyle: currentStyle }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Emit a row and reset style if needed.
|
|
60
|
+
*/
|
|
61
|
+
export function emitRowWithReset(
|
|
62
|
+
buffer: CellBuffer,
|
|
63
|
+
palette: Palette,
|
|
64
|
+
y: number,
|
|
65
|
+
width: number,
|
|
66
|
+
startX = 0,
|
|
67
|
+
endX = width,
|
|
68
|
+
): string {
|
|
69
|
+
const { output, lastStyle } = emitRow(buffer, palette, y, width, startX, endX)
|
|
70
|
+
return lastStyle !== 0 ? output + palette.sgr(0) : output
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a row changed between two buffers.
|
|
75
|
+
*/
|
|
76
|
+
export function rowChanged(prev: CellBuffer, next: CellBuffer, y: number, width: number): boolean {
|
|
77
|
+
const row = y * width
|
|
78
|
+
for (let x = 0; x < width; x++) {
|
|
79
|
+
const idx = row + x
|
|
80
|
+
if (next.g[idx] !== prev.g[idx] || next.s[idx] !== prev.s[idx] || next.cw[idx] !== prev.cw[idx]) {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find the rightmost column with content (non-space or styled).
|
|
89
|
+
* Returns 0 if row is empty.
|
|
90
|
+
*/
|
|
91
|
+
export function rowContentWidth(buffer: CellBuffer, y: number, width: number): number {
|
|
92
|
+
const row = y * width
|
|
93
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
94
|
+
const idx = row + x
|
|
95
|
+
// Skip continuation cells
|
|
96
|
+
if (buffer.cw[idx] === 0) continue
|
|
97
|
+
// Found content if non-space or has style
|
|
98
|
+
if (buffer.g[idx] !== 32 || buffer.s[idx] !== 0) {
|
|
99
|
+
return x + 1
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find the change window between two buffers for a row.
|
|
107
|
+
* Returns the leftmost and rightmost changed columns, or null if no changes.
|
|
108
|
+
*/
|
|
109
|
+
export function findChangeWindow(
|
|
110
|
+
prev: CellBuffer,
|
|
111
|
+
next: CellBuffer,
|
|
112
|
+
y: number,
|
|
113
|
+
width: number,
|
|
114
|
+
): { left: number; right: number } | null {
|
|
115
|
+
const row = y * width
|
|
116
|
+
let left = 0
|
|
117
|
+
let right = width - 1
|
|
118
|
+
|
|
119
|
+
// Find leftmost change
|
|
120
|
+
while (left <= right) {
|
|
121
|
+
const i = row + left
|
|
122
|
+
if (next.g[i] !== prev.g[i] || next.s[i] !== prev.s[i] || next.cw[i] !== prev.cw[i]) {
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
left++
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No changes found
|
|
129
|
+
if (left > right) return null
|
|
130
|
+
|
|
131
|
+
// Find rightmost change
|
|
132
|
+
while (right >= left) {
|
|
133
|
+
const i = row + right
|
|
134
|
+
if (next.g[i] !== prev.g[i] || next.s[i] !== prev.s[i] || next.cw[i] !== prev.cw[i]) {
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
right--
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { left, right }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Find the last row with content in a buffer.
|
|
145
|
+
*/
|
|
146
|
+
export function contentHeight(buffer: CellBuffer, width: number, height: number): number {
|
|
147
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
148
|
+
const row = y * width
|
|
149
|
+
for (let x = 0; x < width; x++) {
|
|
150
|
+
const glyph = buffer.g[row + x]
|
|
151
|
+
const style = buffer.s[row + x]
|
|
152
|
+
if (glyph !== 32 || style !== 0) return y + 1
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return 0
|
|
156
|
+
}
|
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Simple render profiler - accumulates timing stats per phase
|
|
2
|
+
// Enable with: PROFILE_TUI=1
|
|
3
|
+
// Output goes to ./tui-profile.txt
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from "node:fs"
|
|
6
|
+
|
|
7
|
+
const ENABLED = process.env.PROFILE_TUI === "1"
|
|
8
|
+
const OUTPUT_FILE = "tui-profile.txt"
|
|
9
|
+
|
|
10
|
+
interface PhaseStats {
|
|
11
|
+
total: number
|
|
12
|
+
count: number
|
|
13
|
+
min: number
|
|
14
|
+
max: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const phases = new Map<string, PhaseStats>()
|
|
18
|
+
let frameCount = 0
|
|
19
|
+
let frameTotal = 0
|
|
20
|
+
|
|
21
|
+
export function startFrame(): number {
|
|
22
|
+
if (!ENABLED) return 0
|
|
23
|
+
return performance.now()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function endFrame(start: number): void {
|
|
27
|
+
if (!ENABLED) return
|
|
28
|
+
const elapsed = performance.now() - start
|
|
29
|
+
frameCount++
|
|
30
|
+
frameTotal += elapsed
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function startPhase(): number {
|
|
34
|
+
if (!ENABLED) return 0
|
|
35
|
+
return performance.now()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function endPhase(name: string, start: number): void {
|
|
39
|
+
if (!ENABLED) return
|
|
40
|
+
const elapsed = performance.now() - start
|
|
41
|
+
let stats = phases.get(name)
|
|
42
|
+
if (!stats) {
|
|
43
|
+
stats = { total: 0, count: 0, min: Infinity, max: 0 }
|
|
44
|
+
phases.set(name, stats)
|
|
45
|
+
}
|
|
46
|
+
stats.total += elapsed
|
|
47
|
+
stats.count++
|
|
48
|
+
stats.min = Math.min(stats.min, elapsed)
|
|
49
|
+
stats.max = Math.max(stats.max, elapsed)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function dumpStats(): void {
|
|
53
|
+
if (!ENABLED || frameCount === 0) return
|
|
54
|
+
|
|
55
|
+
const lines: string[] = []
|
|
56
|
+
const avgFrame = frameTotal / frameCount
|
|
57
|
+
lines.push(`=== TUI Profile (${frameCount} frames, avg ${avgFrame.toFixed(2)}ms) ===`)
|
|
58
|
+
|
|
59
|
+
// Sort by total time descending
|
|
60
|
+
const sorted = [...phases.entries()].sort((a, b) => b[1].total - a[1].total)
|
|
61
|
+
|
|
62
|
+
for (const [name, stats] of sorted) {
|
|
63
|
+
const avg = stats.total / stats.count
|
|
64
|
+
const pct = ((stats.total / frameTotal) * 100).toFixed(1)
|
|
65
|
+
lines.push(
|
|
66
|
+
` ${name.padEnd(12)} ${avg.toFixed(3)}ms avg ${stats.min.toFixed(3)}-${stats.max.toFixed(3)}ms ${pct}%`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
lines.push("")
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(OUTPUT_FILE, lines.join("\n"))
|
|
73
|
+
// Also print path after alt buffer exit
|
|
74
|
+
setTimeout(() => console.log(`Profile written to ${OUTPUT_FILE}`), 50)
|
|
75
|
+
} catch {
|
|
76
|
+
// Fallback to stderr if file write fails
|
|
77
|
+
console.error(lines.join("\n"))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Auto-dump on exit
|
|
82
|
+
if (ENABLED) {
|
|
83
|
+
process.on("exit", dumpStats)
|
|
84
|
+
process.on("SIGINT", () => {
|
|
85
|
+
dumpStats()
|
|
86
|
+
process.exit(0)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import Reconciler from "react-reconciler"
|
|
2
|
+
import { createContext } from "react"
|
|
3
|
+
import type { HostInstance, HostContext } from "./types.js"
|
|
4
|
+
import { createHostInstance, createTextInstance, type RawTextHost, type BaseHost } from "../hosts/index.js"
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Type Definitions
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/** Container is the root of our host tree */
|
|
11
|
+
export interface Container {
|
|
12
|
+
root: HostInstance | null
|
|
13
|
+
ctx: HostContext
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Props passed to host instances */
|
|
17
|
+
type Props = Record<string, unknown>
|
|
18
|
+
|
|
19
|
+
/** Element type (e.g., "text", "vstack", "box") */
|
|
20
|
+
type Type = string
|
|
21
|
+
|
|
22
|
+
/** Our host instance type */
|
|
23
|
+
type Instance = BaseHost
|
|
24
|
+
|
|
25
|
+
/** Text instance type */
|
|
26
|
+
type TextInstance = RawTextHost
|
|
27
|
+
|
|
28
|
+
/** Host context passed during tree traversal */
|
|
29
|
+
interface ReconcilerHostContext {
|
|
30
|
+
isInsideText: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Priority Management
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
const NoEventPriority = 0
|
|
38
|
+
const DefaultEventPriority = 16
|
|
39
|
+
let currentUpdatePriority = NoEventPriority
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Host Config
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Note: We define methods with proper types but cast the final config to any
|
|
45
|
+
// because react-reconciler's HostConfig type has ~80 required methods and
|
|
46
|
+
// changes frequently between versions. This gives us type safety within
|
|
47
|
+
// each method while avoiding type compatibility issues with the reconciler.
|
|
48
|
+
|
|
49
|
+
const hostConfig = {
|
|
50
|
+
supportsMutation: true,
|
|
51
|
+
supportsPersistence: false,
|
|
52
|
+
supportsHydration: false,
|
|
53
|
+
|
|
54
|
+
createInstance(type: Type, props: Props, rootContainer: Container) {
|
|
55
|
+
return createHostInstance(type, props, rootContainer.ctx)
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
appendChild(parent: Instance, child: Instance) {
|
|
59
|
+
parent.appendChild(child)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
removeChild(parent: Instance, child: Instance) {
|
|
63
|
+
parent.removeChild(child)
|
|
64
|
+
child.destroy()
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
insertBefore(parent: Instance, child: Instance, beforeChild: Instance) {
|
|
68
|
+
parent.insertBefore(child, beforeChild)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance) {
|
|
72
|
+
if (container.root) {
|
|
73
|
+
container.root.insertBefore(child, beforeChild)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
removeChildFromContainer(container: Container, child: Instance) {
|
|
78
|
+
if (container.root) {
|
|
79
|
+
container.root.removeChild(child)
|
|
80
|
+
child.destroy()
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
prepareForCommit() {
|
|
85
|
+
return null
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
resetAfterCommit(container: Container) {
|
|
89
|
+
container.ctx.requestRender()
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
getRootHostContext(): ReconcilerHostContext {
|
|
93
|
+
return { isInsideText: false }
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
getChildHostContext(parentHostContext: ReconcilerHostContext, type: Type): ReconcilerHostContext {
|
|
97
|
+
const isInsideText = type === "text" || parentHostContext.isInsideText
|
|
98
|
+
return { ...parentHostContext, isInsideText }
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
shouldSetTextContent() {
|
|
102
|
+
return false
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
createTextInstance(text: string, rootContainer: Container, hostContext: ReconcilerHostContext): TextInstance {
|
|
106
|
+
// Raw text nodes are only valid inside <text> elements
|
|
107
|
+
if (!hostContext.isInsideText) {
|
|
108
|
+
console.warn("Text nodes should be inside <text> elements")
|
|
109
|
+
}
|
|
110
|
+
return createTextInstance(text, rootContainer.ctx)
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
scheduleTimeout: setTimeout,
|
|
114
|
+
cancelTimeout: clearTimeout,
|
|
115
|
+
noTimeout: -1,
|
|
116
|
+
|
|
117
|
+
shouldAttemptEagerTransition() {
|
|
118
|
+
return false
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
finalizeInitialChildren(instance: Instance, _type: Type, props: Props) {
|
|
122
|
+
instance.updateProps(props)
|
|
123
|
+
return false
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
commitMount() {
|
|
127
|
+
// Could handle focus here
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
commitUpdate(
|
|
131
|
+
instance: Instance,
|
|
132
|
+
_updatePayload: unknown,
|
|
133
|
+
_type: Type,
|
|
134
|
+
_oldProps: Props,
|
|
135
|
+
newProps: Props | { pendingProps?: Props; memoizedProps?: Props },
|
|
136
|
+
) {
|
|
137
|
+
// Handle case where newProps might be a Fiber node (react-reconciler API varies)
|
|
138
|
+
const props =
|
|
139
|
+
(newProps as { pendingProps?: Props }).pendingProps ??
|
|
140
|
+
(newProps as { memoizedProps?: Props }).memoizedProps ??
|
|
141
|
+
(newProps as Props)
|
|
142
|
+
instance.updateProps(props)
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
commitTextUpdate(textInstance: TextInstance, _oldText: string, newText: string) {
|
|
146
|
+
textInstance.updateText(newText)
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
appendChildToContainer(container: Container, child: Instance) {
|
|
150
|
+
// The first child becomes the root
|
|
151
|
+
container.root = child
|
|
152
|
+
child.parent = null
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
appendInitialChild(parent: Instance, child: Instance) {
|
|
156
|
+
parent.appendChild(child)
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
hideInstance() {
|
|
160
|
+
// Could implement visibility
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
unhideInstance() {
|
|
164
|
+
// Could implement visibility
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
hideTextInstance() {},
|
|
168
|
+
|
|
169
|
+
unhideTextInstance() {},
|
|
170
|
+
|
|
171
|
+
clearContainer(container: Container) {
|
|
172
|
+
container.root = null
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
setCurrentUpdatePriority(newPriority: number) {
|
|
176
|
+
currentUpdatePriority = newPriority
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
getCurrentUpdatePriority: () => currentUpdatePriority,
|
|
180
|
+
|
|
181
|
+
resolveUpdatePriority() {
|
|
182
|
+
if (currentUpdatePriority !== NoEventPriority) {
|
|
183
|
+
return currentUpdatePriority
|
|
184
|
+
}
|
|
185
|
+
return DefaultEventPriority
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
maySuspendCommit() {
|
|
189
|
+
return false
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
NotPendingTransition: null,
|
|
193
|
+
|
|
194
|
+
HostTransitionContext: createContext(null),
|
|
195
|
+
|
|
196
|
+
resetFormInstance() {},
|
|
197
|
+
|
|
198
|
+
requestPostPaintCallback() {},
|
|
199
|
+
|
|
200
|
+
trackSchedulerEvent() {},
|
|
201
|
+
|
|
202
|
+
resolveEventType() {
|
|
203
|
+
return null
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
resolveEventTimeStamp() {
|
|
207
|
+
return -1.1
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
preloadInstance() {
|
|
211
|
+
return true
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
startSuspendingCommit() {},
|
|
215
|
+
|
|
216
|
+
suspendInstance() {},
|
|
217
|
+
|
|
218
|
+
waitForCommitToBeReady() {
|
|
219
|
+
return null
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
detachDeletedInstance(instance: Instance) {
|
|
223
|
+
if (!instance.parent) {
|
|
224
|
+
instance.destroy()
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
getPublicInstance(instance: Instance) {
|
|
229
|
+
return instance
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
preparePortalMount() {},
|
|
233
|
+
|
|
234
|
+
isPrimaryRenderer: true,
|
|
235
|
+
|
|
236
|
+
getInstanceFromNode() {
|
|
237
|
+
return null
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
beforeActiveInstanceBlur() {},
|
|
241
|
+
|
|
242
|
+
afterActiveInstanceBlur() {},
|
|
243
|
+
|
|
244
|
+
prepareScopeUpdate() {},
|
|
245
|
+
|
|
246
|
+
getInstanceFromScope() {
|
|
247
|
+
return null
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
prepareUpdate() {
|
|
251
|
+
return true
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Cast to any at the boundary - the config is internally typed but
|
|
256
|
+
// react-reconciler's generic constraints are too strict for real-world usage
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
|
+
export const reconciler = Reconciler(hostConfig as any)
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Reconciler Helpers
|
|
262
|
+
// ============================================================================
|
|
263
|
+
// These helpers guard against API changes between react-reconciler versions
|
|
264
|
+
|
|
265
|
+
type ReconcilerWithOptionalMethods = typeof reconciler & {
|
|
266
|
+
flushPassiveEffects?: () => void
|
|
267
|
+
flushSync?: <T>(fn?: () => T) => T
|
|
268
|
+
batchedUpdates?: <T>(fn: () => T) => T
|
|
269
|
+
discreteUpdates?: <T>(fn: () => T) => T
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const r = reconciler as ReconcilerWithOptionalMethods
|
|
273
|
+
|
|
274
|
+
export const flushPassiveEffects = r.flushPassiveEffects?.bind(r) ?? (() => {})
|
|
275
|
+
export const flushSync = r.flushSync?.bind(r) ?? (<T>(fn?: () => T) => fn?.())
|
|
276
|
+
export const batchedUpdates = r.batchedUpdates?.bind(r) ?? (<T>(fn: () => T) => fn())
|
|
277
|
+
export const discreteUpdates = r.discreteUpdates?.bind(r) ?? (<T>(fn: () => T) => fn())
|