@effect-tui/react 0.11.0 → 0.12.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/components/ListView.js +6 -7
- package/dist/src/components/ListView.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +3 -0
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +113 -74
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hosts/flex-container.d.ts +3 -1
- package/dist/src/hosts/flex-container.d.ts.map +1 -1
- package/dist/src/hosts/flex-container.js +22 -4
- package/dist/src/hosts/flex-container.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +4 -6
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/text.d.ts +13 -0
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +23 -0
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/utils/styles.d.ts +4 -0
- package/dist/src/utils/styles.d.ts.map +1 -1
- package/dist/src/utils/styles.js +4 -0
- package/dist/src/utils/styles.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/ListView.tsx +7 -7
- package/src/hooks/use-scroll.ts +132 -84
- package/src/hosts/flex-container.ts +33 -4
- package/src/hosts/scroll.ts +4 -6
- package/src/hosts/text.ts +32 -0
- package/src/utils/styles.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.12.0",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
|
@@ -90,8 +90,8 @@ export function ListView<T>({
|
|
|
90
90
|
const { state, scrollProps, scrollToVisible } = useScroll({
|
|
91
91
|
enableKeyboard: false, // Parent handles keyboard for selection
|
|
92
92
|
enableMouseWheel: true, // Free scroll with wheel
|
|
93
|
+
contentSize: __debugContentSize ?? totalHeight,
|
|
93
94
|
initialViewportSize: __debugViewportSize,
|
|
94
|
-
initialContentSize: __debugContentSize,
|
|
95
95
|
})
|
|
96
96
|
const viewportMeasured = __debugViewportMeasured ?? state.viewportMeasured
|
|
97
97
|
const viewportSize = __debugViewportSize ?? state.viewportSize
|
|
@@ -121,24 +121,24 @@ export function ListView<T>({
|
|
|
121
121
|
{ action: "press", button: "left" },
|
|
122
122
|
)
|
|
123
123
|
|
|
124
|
-
// Track previous selection to detect changes
|
|
124
|
+
// Track previous selection and viewport to detect changes
|
|
125
125
|
const prevSelectedRef = useRef(selectedIndex)
|
|
126
|
-
|
|
127
|
-
const prevScrollToVisibleRef = useRef(scrollToVisible)
|
|
126
|
+
const prevViewportRef = useRef({ measured: viewportMeasured, size: viewportSize })
|
|
128
127
|
|
|
129
128
|
// Scroll to keep selection visible when it changes OR when viewport size changes
|
|
130
129
|
// useLayoutEffect runs before paint - prevents visible "jump" when navigating
|
|
131
130
|
useLayoutEffect(() => {
|
|
132
131
|
const selectionChanged = selectedIndex !== prevSelectedRef.current
|
|
133
|
-
const viewportChanged =
|
|
132
|
+
const viewportChanged =
|
|
133
|
+
viewportMeasured !== prevViewportRef.current.measured || viewportSize !== prevViewportRef.current.size
|
|
134
134
|
|
|
135
135
|
if (selectionChanged || viewportChanged) {
|
|
136
136
|
prevSelectedRef.current = selectedIndex
|
|
137
|
-
|
|
137
|
+
prevViewportRef.current = { measured: viewportMeasured, size: viewportSize }
|
|
138
138
|
// Pass totalHeight to avoid stale contentSize issues when jumping to end
|
|
139
139
|
scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
|
|
140
140
|
}
|
|
141
|
-
}, [selectedIndex, itemHeight, scrollPadding, scrollToVisible, totalHeight])
|
|
141
|
+
}, [selectedIndex, itemHeight, scrollPadding, scrollToVisible, totalHeight, viewportMeasured, viewportSize])
|
|
142
142
|
|
|
143
143
|
// Also scroll on initial render if selection is not 0
|
|
144
144
|
useEffect(() => {
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// use-scroll.ts — Hook for managing scroll state with keyboard/mouse input
|
|
2
2
|
|
|
3
3
|
import type { KeyMsg } from "@effect-tui/core"
|
|
4
|
-
import { useCallback, useLayoutEffect, useMemo,
|
|
4
|
+
import { useCallback, useLayoutEffect, useMemo, useReducer, useRef } from "react"
|
|
5
5
|
import { useTerminalSize } from "../renderer.js"
|
|
6
6
|
import { useKeyboard } from "./use-keyboard.js"
|
|
7
7
|
|
|
@@ -104,6 +104,8 @@ export interface ScrollState {
|
|
|
104
104
|
export interface UseScrollOptions {
|
|
105
105
|
/** Scroll axis: "vertical" (default) or "horizontal" */
|
|
106
106
|
axis?: "vertical" | "horizontal"
|
|
107
|
+
/** Controlled content size (skips host measurement when provided) */
|
|
108
|
+
contentSize?: number
|
|
107
109
|
/** @internal Initial viewport size override (useful for tests) */
|
|
108
110
|
initialViewportSize?: number
|
|
109
111
|
/** @internal Initial content size override (useful for tests) */
|
|
@@ -150,10 +152,79 @@ export interface UseScrollReturn {
|
|
|
150
152
|
axis: "vertical" | "horizontal"
|
|
151
153
|
onContentSize: (width: number, height: number) => void
|
|
152
154
|
onViewportSize: (width: number, height: number) => void
|
|
155
|
+
onEffectiveOffset?: (offset: number) => void
|
|
153
156
|
onRect?: (x: number, y: number, w: number, h: number) => void
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
type ScrollInternalState = {
|
|
161
|
+
offset: number
|
|
162
|
+
contentSize: number
|
|
163
|
+
viewportSize: number
|
|
164
|
+
viewportMeasured: boolean
|
|
165
|
+
sticky: boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type ScrollAction =
|
|
169
|
+
| { type: "set-viewport"; size: number }
|
|
170
|
+
| { type: "set-content"; size: number }
|
|
171
|
+
| { type: "set-offset"; offset: number }
|
|
172
|
+
| { type: "scroll-by"; delta: number }
|
|
173
|
+
| { type: "sync-effective-offset"; offset: number }
|
|
174
|
+
| { type: "set-sticky"; sticky: boolean }
|
|
175
|
+
|
|
176
|
+
const clampOffset = (offset: number, contentSize: number, viewportSize: number): number => {
|
|
177
|
+
const maxOffset = Math.max(0, contentSize - viewportSize)
|
|
178
|
+
return Math.max(0, Math.min(maxOffset, offset))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const reduceScroll = (state: ScrollInternalState, action: ScrollAction): ScrollInternalState => {
|
|
182
|
+
switch (action.type) {
|
|
183
|
+
case "set-viewport": {
|
|
184
|
+
if (state.viewportSize === action.size && state.viewportMeasured) return state
|
|
185
|
+
const next = {
|
|
186
|
+
...state,
|
|
187
|
+
viewportSize: action.size,
|
|
188
|
+
viewportMeasured: true,
|
|
189
|
+
}
|
|
190
|
+
const clamped = clampOffset(state.offset, next.contentSize, next.viewportSize)
|
|
191
|
+
return clamped === next.offset ? next : { ...next, offset: clamped }
|
|
192
|
+
}
|
|
193
|
+
case "set-content": {
|
|
194
|
+
if (state.contentSize === action.size) return state
|
|
195
|
+
const prevMaxOffset = Math.max(0, state.contentSize - state.viewportSize)
|
|
196
|
+
const wasAtEnd = state.offset >= prevMaxOffset - 1
|
|
197
|
+
const next = { ...state, contentSize: action.size }
|
|
198
|
+
if (state.sticky && wasAtEnd) {
|
|
199
|
+
const maxOffset = Math.max(0, action.size - state.viewportSize)
|
|
200
|
+
return maxOffset === next.offset ? next : { ...next, offset: maxOffset }
|
|
201
|
+
}
|
|
202
|
+
const clamped = clampOffset(state.offset, action.size, state.viewportSize)
|
|
203
|
+
return clamped === next.offset ? next : { ...next, offset: clamped }
|
|
204
|
+
}
|
|
205
|
+
case "set-offset": {
|
|
206
|
+
const clamped = clampOffset(action.offset, state.contentSize, state.viewportSize)
|
|
207
|
+
if (clamped === state.offset) return state
|
|
208
|
+
return { ...state, offset: clamped }
|
|
209
|
+
}
|
|
210
|
+
case "scroll-by": {
|
|
211
|
+
if (action.delta === 0) return state
|
|
212
|
+
const clamped = clampOffset(state.offset + action.delta, state.contentSize, state.viewportSize)
|
|
213
|
+
if (clamped === state.offset) return state
|
|
214
|
+
return { ...state, offset: clamped }
|
|
215
|
+
}
|
|
216
|
+
case "sync-effective-offset": {
|
|
217
|
+
const clamped = clampOffset(action.offset, state.contentSize, state.viewportSize)
|
|
218
|
+
if (clamped === state.offset) return state
|
|
219
|
+
return { ...state, offset: clamped }
|
|
220
|
+
}
|
|
221
|
+
case "set-sticky": {
|
|
222
|
+
if (state.sticky === action.sticky) return state
|
|
223
|
+
return { ...state, sticky: action.sticky }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
157
228
|
/**
|
|
158
229
|
* Hook for managing scroll state with keyboard/mouse input.
|
|
159
230
|
*
|
|
@@ -175,6 +246,7 @@ export interface UseScrollReturn {
|
|
|
175
246
|
export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
176
247
|
const {
|
|
177
248
|
axis = "vertical",
|
|
249
|
+
contentSize: controlledContentSize,
|
|
178
250
|
initialViewportSize,
|
|
179
251
|
initialContentSize,
|
|
180
252
|
initialOffset = 0,
|
|
@@ -188,17 +260,18 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
188
260
|
|
|
189
261
|
const { width: termWidth, height: termHeight } = useTerminalSize()
|
|
190
262
|
const baseViewportSize = initialViewportSize ?? (axis === "vertical" ? termHeight : termWidth)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const [
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
263
|
+
const baseContentSize = controlledContentSize ?? initialContentSize ?? 0
|
|
264
|
+
|
|
265
|
+
const [internal, dispatch] = useReducer(reduceScroll, {
|
|
266
|
+
offset: clampOffset(initialOffset, baseContentSize, baseViewportSize),
|
|
267
|
+
contentSize: baseContentSize,
|
|
268
|
+
viewportSize: baseViewportSize,
|
|
269
|
+
viewportMeasured: false,
|
|
270
|
+
sticky,
|
|
271
|
+
})
|
|
272
|
+
const stateRef = useRef(internal)
|
|
273
|
+
stateRef.current = internal
|
|
274
|
+
const viewportSizeRef = useRef(baseViewportSize)
|
|
202
275
|
|
|
203
276
|
// Scroll acceleration
|
|
204
277
|
const accel = useMemo(
|
|
@@ -209,35 +282,10 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
209
282
|
// Fractional accumulator for smooth sub-pixel scrolling
|
|
210
283
|
const accumulatorRef = useRef(0)
|
|
211
284
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const viewportSizeRef = useRef(viewportSize)
|
|
217
|
-
|
|
218
|
-
// Calculate derived state
|
|
219
|
-
const maxOffset = Math.max(0, contentSize - viewportSize)
|
|
220
|
-
const atStart = offset <= 0
|
|
221
|
-
const atEnd = offset >= maxOffset
|
|
222
|
-
|
|
223
|
-
const clampOffset = useCallback(
|
|
224
|
-
(value: number) => {
|
|
225
|
-
const clamped = Math.max(0, Math.min(maxOffset, value))
|
|
226
|
-
// Keep refs in sync so rapid events don't use stale state
|
|
227
|
-
offsetRef.current = clamped
|
|
228
|
-
wasAtEndRef.current = clamped >= maxOffset - 1
|
|
229
|
-
return clamped
|
|
230
|
-
},
|
|
231
|
-
[maxOffset],
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
// Clamp and set offset
|
|
235
|
-
const setOffset = useCallback(
|
|
236
|
-
(newOffset: number) => {
|
|
237
|
-
setOffsetRaw(() => clampOffset(newOffset))
|
|
238
|
-
},
|
|
239
|
-
[clampOffset],
|
|
240
|
-
)
|
|
285
|
+
// Keep sticky state in sync when options change
|
|
286
|
+
useLayoutEffect(() => {
|
|
287
|
+
dispatch({ type: "set-sticky", sticky })
|
|
288
|
+
}, [sticky])
|
|
241
289
|
|
|
242
290
|
// Scroll by delta with accumulator for fractional scrolling
|
|
243
291
|
// Uses functional state updates to avoid stale closures during rapid scrolling
|
|
@@ -246,55 +294,50 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
246
294
|
accumulatorRef.current += delta
|
|
247
295
|
const integerDelta = Math.trunc(accumulatorRef.current)
|
|
248
296
|
if (integerDelta !== 0) {
|
|
249
|
-
|
|
297
|
+
dispatch({ type: "scroll-by", delta: integerDelta })
|
|
250
298
|
accumulatorRef.current -= integerDelta
|
|
251
299
|
}
|
|
252
300
|
},
|
|
253
|
-
[
|
|
301
|
+
[dispatch],
|
|
254
302
|
)
|
|
255
303
|
|
|
256
304
|
const scrollToStart = useCallback(() => {
|
|
257
|
-
|
|
305
|
+
dispatch({ type: "set-offset", offset: 0 })
|
|
258
306
|
accumulatorRef.current = 0
|
|
259
307
|
accel.reset()
|
|
260
|
-
}, [
|
|
308
|
+
}, [dispatch, accel])
|
|
261
309
|
|
|
262
310
|
const scrollToEnd = useCallback(() => {
|
|
263
|
-
|
|
311
|
+
const current = stateRef.current
|
|
312
|
+
const maxOffset = Math.max(0, current.contentSize - current.viewportSize)
|
|
313
|
+
dispatch({ type: "set-offset", offset: maxOffset })
|
|
264
314
|
accumulatorRef.current = 0
|
|
265
315
|
accel.reset()
|
|
266
|
-
}, [
|
|
316
|
+
}, [dispatch, accel])
|
|
267
317
|
|
|
268
318
|
// Handle content size changes (for sticky scroll)
|
|
269
319
|
const handleContentSize = useCallback(
|
|
270
320
|
(width: number, height: number) => {
|
|
321
|
+
if (controlledContentSize !== undefined) return
|
|
271
322
|
const newSize = axis === "vertical" ? height : width
|
|
272
|
-
|
|
273
|
-
prevContentSizeRef.current = newSize
|
|
323
|
+
dispatch({ type: "set-content", size: newSize })
|
|
274
324
|
},
|
|
275
|
-
[axis],
|
|
325
|
+
[axis, controlledContentSize],
|
|
276
326
|
)
|
|
277
327
|
|
|
278
|
-
// Synchronous sticky scroll: snap to end when content grows
|
|
279
|
-
// useLayoutEffect runs synchronously after React commit but before paint
|
|
280
328
|
useLayoutEffect(() => {
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
setOffsetRaw(() => clampOffset(newMaxOffset))
|
|
285
|
-
}
|
|
286
|
-
}, [sticky, contentSize, viewportSize, clampOffset])
|
|
329
|
+
if (controlledContentSize === undefined) return
|
|
330
|
+
dispatch({ type: "set-content", size: controlledContentSize })
|
|
331
|
+
}, [controlledContentSize])
|
|
287
332
|
|
|
288
333
|
// Handle viewport size changes (reported by scroll component)
|
|
289
334
|
const handleViewportSize = useCallback(
|
|
290
335
|
(width: number, height: number) => {
|
|
291
336
|
const newSize = axis === "vertical" ? height : width
|
|
292
|
-
// Sync ref immediately so scrollToVisible uses correct size
|
|
293
337
|
viewportSizeRef.current = newSize
|
|
294
|
-
|
|
295
|
-
setViewportMeasured(true)
|
|
338
|
+
dispatch({ type: "set-viewport", size: newSize })
|
|
296
339
|
},
|
|
297
|
-
[axis],
|
|
340
|
+
[axis, dispatch],
|
|
298
341
|
)
|
|
299
342
|
|
|
300
343
|
// Keyboard handler
|
|
@@ -323,10 +366,10 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
323
366
|
scrollBy(arrowSpeed)
|
|
324
367
|
break
|
|
325
368
|
case "pageup":
|
|
326
|
-
scrollBy(-Math.floor(
|
|
369
|
+
scrollBy(-Math.floor(viewportSizeRef.current * pageSpeed))
|
|
327
370
|
break
|
|
328
371
|
case "pagedown":
|
|
329
|
-
scrollBy(Math.floor(
|
|
372
|
+
scrollBy(Math.floor(viewportSizeRef.current * pageSpeed))
|
|
330
373
|
break
|
|
331
374
|
case "home":
|
|
332
375
|
scrollToStart()
|
|
@@ -341,7 +384,6 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
341
384
|
axis,
|
|
342
385
|
arrowSpeed,
|
|
343
386
|
pageSpeed,
|
|
344
|
-
viewportSize,
|
|
345
387
|
enableMouseWheel,
|
|
346
388
|
accel,
|
|
347
389
|
scrollBy,
|
|
@@ -358,54 +400,60 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
358
400
|
// Bypasses clampOffset because it uses totalSize for accurate clamping
|
|
359
401
|
const scrollToVisible = useCallback(
|
|
360
402
|
(position: number, itemSize = 1, padding = 0, totalSize?: number) => {
|
|
361
|
-
const
|
|
403
|
+
const current = stateRef.current
|
|
404
|
+
const currentOffset = current.offset
|
|
362
405
|
const currentViewportSize = viewportSizeRef.current
|
|
363
406
|
const itemStart = position * itemSize
|
|
364
407
|
const itemEnd = itemStart + itemSize
|
|
365
408
|
// Use provided totalSize if available (more accurate than potentially stale contentSize)
|
|
366
|
-
const effectiveContentSize = totalSize ?? contentSize
|
|
409
|
+
const effectiveContentSize = totalSize ?? current.contentSize
|
|
367
410
|
const isAbove = itemStart < currentOffset + padding
|
|
368
411
|
const isBelow = itemEnd > currentOffset + currentViewportSize - padding
|
|
369
412
|
|
|
370
413
|
// If item is above viewport, scroll up to show it
|
|
371
414
|
if (isAbove) {
|
|
372
415
|
const newOffset = Math.max(0, itemStart - padding)
|
|
373
|
-
|
|
374
|
-
wasAtEndRef.current = false
|
|
375
|
-
setOffsetRaw(newOffset)
|
|
416
|
+
dispatch({ type: "set-offset", offset: newOffset })
|
|
376
417
|
}
|
|
377
418
|
// If item is below viewport, scroll down to show it
|
|
378
419
|
else if (isBelow) {
|
|
379
420
|
const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
|
|
380
421
|
const newOffset = Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding)
|
|
381
|
-
|
|
382
|
-
wasAtEndRef.current = newOffset >= currentMaxOffset - 1
|
|
383
|
-
setOffsetRaw(newOffset)
|
|
422
|
+
dispatch({ type: "set-offset", offset: newOffset })
|
|
384
423
|
}
|
|
385
424
|
},
|
|
386
|
-
[
|
|
425
|
+
[dispatch],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
const setOffset = useCallback(
|
|
429
|
+
(newOffset: number) => {
|
|
430
|
+
dispatch({ type: "set-offset", offset: newOffset })
|
|
431
|
+
},
|
|
432
|
+
[dispatch],
|
|
387
433
|
)
|
|
388
434
|
|
|
435
|
+
// Calculate derived state
|
|
436
|
+
const maxOffset = Math.max(0, internal.contentSize - internal.viewportSize)
|
|
437
|
+
const atStart = internal.offset <= 0
|
|
438
|
+
const atEnd = internal.offset >= maxOffset
|
|
439
|
+
|
|
389
440
|
const state: ScrollState = {
|
|
390
|
-
offset,
|
|
441
|
+
offset: internal.offset,
|
|
391
442
|
maxOffset,
|
|
392
|
-
viewportSize,
|
|
393
|
-
viewportMeasured,
|
|
394
|
-
contentSize,
|
|
443
|
+
viewportSize: internal.viewportSize,
|
|
444
|
+
viewportMeasured: internal.viewportMeasured,
|
|
445
|
+
contentSize: internal.contentSize,
|
|
395
446
|
atStart,
|
|
396
447
|
atEnd,
|
|
397
448
|
}
|
|
398
449
|
|
|
399
450
|
// Handle effective offset sync from host (when sticky adjusts the offset)
|
|
400
451
|
const handleEffectiveOffset = useCallback((effectiveOffset: number) => {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
// Also update state to keep in sync (but don't trigger wasAtEnd change)
|
|
404
|
-
setOffsetRaw(effectiveOffset)
|
|
405
|
-
}, [])
|
|
452
|
+
dispatch({ type: "sync-effective-offset", offset: effectiveOffset })
|
|
453
|
+
}, [dispatch])
|
|
406
454
|
|
|
407
455
|
const scrollProps = {
|
|
408
|
-
offset,
|
|
456
|
+
offset: internal.offset,
|
|
409
457
|
axis,
|
|
410
458
|
onContentSize: handleContentSize,
|
|
411
459
|
onViewportSize: handleViewportSize,
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
import type { CellBuffer, Color, Palette } from "@effect-tui/core"
|
|
3
3
|
import { splitConstraints } from "@effect-tui/core"
|
|
4
4
|
import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type FlexAlignment,
|
|
7
|
+
type FlexAxis,
|
|
8
|
+
layoutFlex,
|
|
9
|
+
measureFlex,
|
|
10
|
+
type Padding,
|
|
11
|
+
type PaddingInput,
|
|
12
|
+
resolveBgStyle,
|
|
13
|
+
resolvePadding,
|
|
14
|
+
} from "../utils/index.js"
|
|
6
15
|
import { BaseHost } from "./base.js"
|
|
7
16
|
|
|
8
17
|
export type CrossAlignment<A extends FlexAxis> = A extends "vertical"
|
|
@@ -12,6 +21,7 @@ export type CrossAlignment<A extends FlexAxis> = A extends "vertical"
|
|
|
12
21
|
export interface FlexContainerProps<A extends FlexAxis> extends CommonProps {
|
|
13
22
|
spacing?: number
|
|
14
23
|
alignment?: CrossAlignment<A>
|
|
24
|
+
padding?: PaddingInput
|
|
15
25
|
/** Background color to fill the entire container rect */
|
|
16
26
|
bg?: Color
|
|
17
27
|
}
|
|
@@ -34,6 +44,7 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
|
|
|
34
44
|
spacing = 0
|
|
35
45
|
alignment!: CrossAlignment<A> // Set in constructor before updateProps
|
|
36
46
|
bg?: Color
|
|
47
|
+
padding: Padding = { top: 0, right: 0, bottom: 0, left: 0 }
|
|
37
48
|
private cachedSizes: Size[] = []
|
|
38
49
|
private layoutChildren: typeof this.children = []
|
|
39
50
|
|
|
@@ -71,15 +82,24 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
|
|
|
71
82
|
measure(maxW: number, maxH: number): Size {
|
|
72
83
|
// Apply frame constraints to what we propose to children
|
|
73
84
|
const constrained = this.constrainProposal(maxW, maxH)
|
|
85
|
+
const insetX = this.padding.left + this.padding.right
|
|
86
|
+
const insetY = this.padding.top + this.padding.bottom
|
|
87
|
+
const innerW = Math.max(0, constrained.w - insetX)
|
|
88
|
+
const innerH = Math.max(0, constrained.h - insetY)
|
|
74
89
|
|
|
75
90
|
// Exclude __static children from normal layout - they're rendered to scrollback separately
|
|
76
91
|
this.layoutChildren = this.getNonStaticChildren()
|
|
77
|
-
const [maxMain, maxCross] = splitConstraints(this.axis,
|
|
92
|
+
const [maxMain, maxCross] = splitConstraints(this.axis, innerW, innerH)
|
|
78
93
|
const result = measureFlex(this.axis, this.layoutChildren, this.spacing, maxMain, maxCross)
|
|
79
94
|
this.cachedSizes = result.sizes
|
|
80
95
|
|
|
96
|
+
const paddedSize: Size = {
|
|
97
|
+
w: result.totalSize.w + insetX,
|
|
98
|
+
h: result.totalSize.h + insetY,
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
// Apply frame constraints to what we report to parent
|
|
82
|
-
return this.constrainResult(
|
|
102
|
+
return this.constrainResult(paddedSize)
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
override layout(rect: Rect): void {
|
|
@@ -87,11 +107,19 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
|
|
|
87
107
|
// Use this.rect (constrained) not rect (raw input)
|
|
88
108
|
if (!this.rect) return
|
|
89
109
|
const stretchCross = this.axis === "vertical" ? this.alignment === "leading" : this.alignment === "top"
|
|
110
|
+
const insetX = this.padding.left + this.padding.right
|
|
111
|
+
const insetY = this.padding.top + this.padding.bottom
|
|
112
|
+
const innerRect: Rect = {
|
|
113
|
+
x: this.rect.x + this.padding.left,
|
|
114
|
+
y: this.rect.y + this.padding.top,
|
|
115
|
+
w: Math.max(0, this.rect.w - insetX),
|
|
116
|
+
h: Math.max(0, this.rect.h - insetY),
|
|
117
|
+
}
|
|
90
118
|
layoutFlex(
|
|
91
119
|
this.axis,
|
|
92
120
|
this.layoutChildren,
|
|
93
121
|
this.cachedSizes,
|
|
94
|
-
|
|
122
|
+
innerRect,
|
|
95
123
|
this.spacing,
|
|
96
124
|
toFlexAlignment(this.axis, this.alignment),
|
|
97
125
|
stretchCross,
|
|
@@ -121,6 +149,7 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
|
|
|
121
149
|
this.alignment =
|
|
122
150
|
(props.alignment as CrossAlignment<A> | undefined) ??
|
|
123
151
|
((this.axis === "vertical" ? "leading" : "top") as CrossAlignment<A>)
|
|
152
|
+
this.padding = resolvePadding(props.padding as FlexContainerProps<A>["padding"])
|
|
124
153
|
this.bg = props.bg as Color | undefined
|
|
125
154
|
}
|
|
126
155
|
}
|
package/src/hosts/scroll.ts
CHANGED
|
@@ -157,8 +157,8 @@ export class ScrollHost extends SingleChildHost {
|
|
|
157
157
|
scrollX = Math.max(0, Math.min(maxScrollX, scrollX))
|
|
158
158
|
|
|
159
159
|
// Store effective offsets for rendering (scrollbar position)
|
|
160
|
-
// Report back if changed (
|
|
161
|
-
if (scrollY !== this.effectiveOffset) {
|
|
160
|
+
// Report back if clamped or changed (keep controller in sync)
|
|
161
|
+
if (scrollY !== this.effectiveOffset || scrollY !== this.offset) {
|
|
162
162
|
this.onEffectiveOffset?.(scrollY)
|
|
163
163
|
}
|
|
164
164
|
this.effectiveOffset = scrollY
|
|
@@ -191,10 +191,8 @@ export class ScrollHost extends SingleChildHost {
|
|
|
191
191
|
const { x, y, w, h } = this.rect
|
|
192
192
|
|
|
193
193
|
// Fill background (inherit from parent if not explicitly set)
|
|
194
|
-
const {
|
|
195
|
-
|
|
196
|
-
buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
|
|
197
|
-
}
|
|
194
|
+
const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
|
|
195
|
+
buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
|
|
198
196
|
|
|
199
197
|
// Render children with clipping
|
|
200
198
|
buffer.withClip(x, y, w, h, () => {
|
package/src/hosts/text.ts
CHANGED
|
@@ -11,8 +11,10 @@ export interface TextProps extends CommonProps {
|
|
|
11
11
|
fg?: ColorProp
|
|
12
12
|
bg?: ColorProp
|
|
13
13
|
bold?: boolean
|
|
14
|
+
dimmed?: boolean
|
|
14
15
|
italic?: boolean
|
|
15
16
|
underline?: boolean
|
|
17
|
+
strikethrough?: boolean
|
|
16
18
|
inverse?: boolean
|
|
17
19
|
/** If true, wrap text to multiple lines (default: false, text is truncated) */
|
|
18
20
|
wrap?: boolean
|
|
@@ -22,8 +24,10 @@ export class TextHost extends BaseHost {
|
|
|
22
24
|
fg?: Color
|
|
23
25
|
bg?: Color
|
|
24
26
|
bold = false
|
|
27
|
+
dimmed = false
|
|
25
28
|
italic = false
|
|
26
29
|
underline = false
|
|
30
|
+
strikethrough = false
|
|
27
31
|
inverse = false
|
|
28
32
|
wrap = false // Default: truncate (no wrap)
|
|
29
33
|
|
|
@@ -76,8 +80,11 @@ export class TextHost extends BaseHost {
|
|
|
76
80
|
fg: this.fg,
|
|
77
81
|
bg: this.bg,
|
|
78
82
|
bold: this.bold,
|
|
83
|
+
dimmed: this.dimmed,
|
|
79
84
|
italic: this.italic,
|
|
80
85
|
underline: this.underline,
|
|
86
|
+
strikethrough: this.strikethrough,
|
|
87
|
+
inverse: this.inverse,
|
|
81
88
|
})
|
|
82
89
|
}
|
|
83
90
|
} else if (child instanceof SpanHost) {
|
|
@@ -89,8 +96,11 @@ export class TextHost extends BaseHost {
|
|
|
89
96
|
fg: child.fg ?? this.fg,
|
|
90
97
|
bg: child.bg ?? this.bg,
|
|
91
98
|
bold: child.bold || this.bold,
|
|
99
|
+
dimmed: child.dimmed || this.dimmed,
|
|
92
100
|
italic: child.italic || this.italic,
|
|
93
101
|
underline: child.underline || this.underline,
|
|
102
|
+
strikethrough: child.strikethrough || this.strikethrough,
|
|
103
|
+
inverse: child.inverse || this.inverse,
|
|
94
104
|
})
|
|
95
105
|
}
|
|
96
106
|
}
|
|
@@ -297,8 +307,11 @@ export class TextHost extends BaseHost {
|
|
|
297
307
|
fg: span.fg ?? this.fg,
|
|
298
308
|
bg: span.bg ?? inheritedBg,
|
|
299
309
|
bold: span.bold,
|
|
310
|
+
dimmed: span.dimmed,
|
|
300
311
|
italic: span.italic,
|
|
301
312
|
underline: span.underline,
|
|
313
|
+
strikethrough: span.strikethrough,
|
|
314
|
+
inverse: span.inverse,
|
|
302
315
|
})
|
|
303
316
|
const availableWidth = this.rect.w - (x - this.rect.x)
|
|
304
317
|
if (availableWidth <= 0) break
|
|
@@ -315,8 +328,10 @@ export class TextHost extends BaseHost {
|
|
|
315
328
|
fg: this.fg,
|
|
316
329
|
bg: inheritedBg,
|
|
317
330
|
bold: this.bold,
|
|
331
|
+
dimmed: this.dimmed,
|
|
318
332
|
italic: this.italic,
|
|
319
333
|
underline: this.underline,
|
|
334
|
+
strikethrough: this.strikethrough,
|
|
320
335
|
inverse: this.inverse,
|
|
321
336
|
})
|
|
322
337
|
const content = this.getContent()
|
|
@@ -366,8 +381,10 @@ export class TextHost extends BaseHost {
|
|
|
366
381
|
this.bg = v as Color
|
|
367
382
|
}) as Color | undefined
|
|
368
383
|
this.bold = Boolean(props.bold)
|
|
384
|
+
this.dimmed = Boolean(props.dimmed)
|
|
369
385
|
this.italic = Boolean(props.italic)
|
|
370
386
|
this.underline = Boolean(props.underline)
|
|
387
|
+
this.strikethrough = Boolean(props.strikethrough)
|
|
371
388
|
this.inverse = Boolean(props.inverse)
|
|
372
389
|
this.wrap = Boolean(props.wrap)
|
|
373
390
|
}
|
|
@@ -415,8 +432,10 @@ export interface SpanStyle {
|
|
|
415
432
|
fg?: Color
|
|
416
433
|
bg?: Color
|
|
417
434
|
bold?: boolean
|
|
435
|
+
dimmed?: boolean
|
|
418
436
|
italic?: boolean
|
|
419
437
|
underline?: boolean
|
|
438
|
+
strikethrough?: boolean
|
|
420
439
|
inverse?: boolean
|
|
421
440
|
}
|
|
422
441
|
|
|
@@ -424,8 +443,10 @@ export interface SpanProps extends CommonProps {
|
|
|
424
443
|
fg?: Color
|
|
425
444
|
bg?: Color
|
|
426
445
|
bold?: boolean
|
|
446
|
+
dimmed?: boolean
|
|
427
447
|
italic?: boolean
|
|
428
448
|
underline?: boolean
|
|
449
|
+
strikethrough?: boolean
|
|
429
450
|
inverse?: boolean
|
|
430
451
|
/** Reusable style object. Individual props override textStyle values. */
|
|
431
452
|
textStyle?: SpanStyle
|
|
@@ -441,8 +462,10 @@ export class SpanHost extends BaseHost {
|
|
|
441
462
|
fg?: Color
|
|
442
463
|
bg?: Color
|
|
443
464
|
bold = false
|
|
465
|
+
dimmed = false
|
|
444
466
|
italic = false
|
|
445
467
|
underline = false
|
|
468
|
+
strikethrough = false
|
|
446
469
|
inverse = false
|
|
447
470
|
|
|
448
471
|
constructor(props: SpanProps, ctx: HostContext) {
|
|
@@ -475,8 +498,11 @@ export class SpanHost extends BaseHost {
|
|
|
475
498
|
this.fg = props.fg !== undefined ? (props.fg as Color) : textStyle?.fg
|
|
476
499
|
this.bg = props.bg !== undefined ? (props.bg as Color) : textStyle?.bg
|
|
477
500
|
this.bold = props.bold !== undefined ? Boolean(props.bold) : Boolean(textStyle?.bold)
|
|
501
|
+
this.dimmed = props.dimmed !== undefined ? Boolean(props.dimmed) : Boolean(textStyle?.dimmed)
|
|
478
502
|
this.italic = props.italic !== undefined ? Boolean(props.italic) : Boolean(textStyle?.italic)
|
|
479
503
|
this.underline = props.underline !== undefined ? Boolean(props.underline) : Boolean(textStyle?.underline)
|
|
504
|
+
this.strikethrough =
|
|
505
|
+
props.strikethrough !== undefined ? Boolean(props.strikethrough) : Boolean(textStyle?.strikethrough)
|
|
480
506
|
this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
|
|
481
507
|
}
|
|
482
508
|
}
|
|
@@ -491,8 +517,11 @@ export interface StyledSpan {
|
|
|
491
517
|
fg?: Color
|
|
492
518
|
bg?: Color
|
|
493
519
|
bold?: boolean
|
|
520
|
+
dimmed?: boolean
|
|
494
521
|
italic?: boolean
|
|
495
522
|
underline?: boolean
|
|
523
|
+
strikethrough?: boolean
|
|
524
|
+
inverse?: boolean
|
|
496
525
|
}
|
|
497
526
|
|
|
498
527
|
export interface StyledTextProps extends CommonProps {
|
|
@@ -627,8 +656,11 @@ export class StyledTextHost extends BaseHost {
|
|
|
627
656
|
fg: span.fg ?? this.fg,
|
|
628
657
|
bg: span.bg ?? inheritedBg,
|
|
629
658
|
bold: span.bold,
|
|
659
|
+
dimmed: span.dimmed,
|
|
630
660
|
italic: span.italic,
|
|
631
661
|
underline: span.underline,
|
|
662
|
+
strikethrough: span.strikethrough,
|
|
663
|
+
inverse: span.inverse,
|
|
632
664
|
})
|
|
633
665
|
const availableWidth = this.rect.w - (x - this.rect.x)
|
|
634
666
|
if (availableWidth <= 0) break
|