@effect-tui/react 0.10.2 → 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.
Files changed (50) hide show
  1. package/dist/jsx-runtime.d.ts +1 -1
  2. package/dist/jsx-runtime.d.ts.map +1 -1
  3. package/dist/src/components/ListView.js +6 -7
  4. package/dist/src/components/ListView.js.map +1 -1
  5. package/dist/src/hooks/use-scroll.d.ts +3 -0
  6. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  7. package/dist/src/hooks/use-scroll.js +113 -74
  8. package/dist/src/hooks/use-scroll.js.map +1 -1
  9. package/dist/src/hosts/box.js.map +1 -1
  10. package/dist/src/hosts/canvas.js.map +1 -1
  11. package/dist/src/hosts/codeblock.js.map +1 -1
  12. package/dist/src/hosts/flex-container.d.ts +3 -1
  13. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  14. package/dist/src/hosts/flex-container.js +22 -4
  15. package/dist/src/hosts/flex-container.js.map +1 -1
  16. package/dist/src/hosts/overlay-item.js.map +1 -1
  17. package/dist/src/hosts/scroll.d.ts.map +1 -1
  18. package/dist/src/hosts/scroll.js +4 -6
  19. package/dist/src/hosts/scroll.js.map +1 -1
  20. package/dist/src/hosts/spacer.js.map +1 -1
  21. package/dist/src/hosts/text.d.ts +13 -0
  22. package/dist/src/hosts/text.d.ts.map +1 -1
  23. package/dist/src/hosts/text.js +23 -0
  24. package/dist/src/hosts/text.js.map +1 -1
  25. package/dist/src/hosts/zstack.js.map +1 -1
  26. package/dist/src/reconciler/types.d.ts +0 -5
  27. package/dist/src/reconciler/types.d.ts.map +1 -1
  28. package/dist/src/test-grow.d.ts.map +1 -1
  29. package/dist/src/test-grow.js +0 -2
  30. package/dist/src/test-grow.js.map +1 -1
  31. package/dist/src/utils/styles.d.ts +4 -0
  32. package/dist/src/utils/styles.d.ts.map +1 -1
  33. package/dist/src/utils/styles.js +4 -0
  34. package/dist/src/utils/styles.js.map +1 -1
  35. package/dist/tsconfig.tsbuildinfo +1 -1
  36. package/jsx-runtime.ts +2 -3
  37. package/package.json +2 -2
  38. package/src/components/ListView.tsx +7 -7
  39. package/src/hooks/use-scroll.ts +132 -84
  40. package/src/hosts/box.ts +1 -1
  41. package/src/hosts/canvas.ts +1 -1
  42. package/src/hosts/codeblock.ts +1 -1
  43. package/src/hosts/flex-container.ts +34 -5
  44. package/src/hosts/overlay-item.ts +1 -1
  45. package/src/hosts/scroll.ts +5 -7
  46. package/src/hosts/spacer.ts +1 -1
  47. package/src/hosts/text.ts +35 -3
  48. package/src/hosts/zstack.ts +1 -1
  49. package/src/reconciler/types.ts +0 -6
  50. package/src/utils/styles.ts +6 -0
package/jsx-runtime.ts CHANGED
@@ -36,9 +36,8 @@ export declare namespace JSX {
36
36
 
37
37
  export interface IntrinsicAttributes extends React.Attributes {}
38
38
 
39
- // Extend React's intrinsic elements but override our custom ones
40
- export interface IntrinsicElements extends React.JSX.IntrinsicElements {
41
- // Our custom TUI elements (override any React conflicts)
39
+ // Custom TUI elements only - no React DOM elements
40
+ export interface IntrinsicElements {
42
41
  text: TextProps & { children?: React.ReactNode }
43
42
  span: SpanProps & { children?: React.ReactNode }
44
43
  styledtext: StyledTextProps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.10.2",
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.10.2",
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
- // Track previous scrollToVisible reference to detect viewport changes
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 = scrollToVisible !== prevScrollToVisibleRef.current
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
- prevScrollToVisibleRef.current = scrollToVisible
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(() => {
@@ -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, useRef, useState } from "react"
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
- // Scroll state
193
- const [offset, setOffsetRaw] = useState(initialOffset)
194
- const [contentSize, setContentSize] = useState(initialContentSize ?? 0)
195
- // Use terminal size as initial estimate, but scroll component will report actual size
196
- const [viewportSize, setViewportSize] = useState(baseViewportSize)
197
- const [viewportMeasured, setViewportMeasured] = useState(false)
198
-
199
- // Refs for sticky scroll behavior
200
- const wasAtEndRef = useRef(sticky)
201
- const prevContentSizeRef = useRef(0)
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
- // Refs for scrollToVisible so it doesn't change on every scroll
213
- const offsetRef = useRef(offset)
214
- offsetRef.current = offset
215
- // viewportSizeRef is ONLY updated by handleViewportSize to avoid stale state overwriting
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
- setOffsetRaw((prev) => clampOffset(prev + integerDelta))
297
+ dispatch({ type: "scroll-by", delta: integerDelta })
250
298
  accumulatorRef.current -= integerDelta
251
299
  }
252
300
  },
253
- [clampOffset],
301
+ [dispatch],
254
302
  )
255
303
 
256
304
  const scrollToStart = useCallback(() => {
257
- setOffset(0)
305
+ dispatch({ type: "set-offset", offset: 0 })
258
306
  accumulatorRef.current = 0
259
307
  accel.reset()
260
- }, [setOffset, accel])
308
+ }, [dispatch, accel])
261
309
 
262
310
  const scrollToEnd = useCallback(() => {
263
- setOffset(maxOffset)
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
- }, [setOffset, maxOffset, accel])
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
- setContentSize(newSize)
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 (!sticky) return
282
- const newMaxOffset = Math.max(0, contentSize - viewportSize)
283
- if (wasAtEndRef.current && contentSize > 0) {
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
- setViewportSize(newSize)
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(viewportSize * pageSpeed))
369
+ scrollBy(-Math.floor(viewportSizeRef.current * pageSpeed))
327
370
  break
328
371
  case "pagedown":
329
- scrollBy(Math.floor(viewportSize * pageSpeed))
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 currentOffset = offsetRef.current
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
- offsetRef.current = newOffset
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
- offsetRef.current = newOffset
382
- wasAtEndRef.current = newOffset >= currentMaxOffset - 1
383
- setOffsetRaw(newOffset)
422
+ dispatch({ type: "set-offset", offset: newOffset })
384
423
  }
385
424
  },
386
- [contentSize, viewportSize],
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
- // Sync the ref so scrollToVisible uses the actual rendered position
402
- offsetRef.current = effectiveOffset
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,
package/src/hosts/box.ts CHANGED
@@ -40,7 +40,7 @@ export class BoxHost extends SingleChildHost {
40
40
 
41
41
  constructor(props: BoxProps, ctx: HostContext) {
42
42
  super("box", props, ctx)
43
- this.updateProps(props as Record<string, unknown>)
43
+ this.updateProps(props as unknown as Record<string, unknown>)
44
44
  }
45
45
 
46
46
  private get borderThickness(): number {
@@ -75,7 +75,7 @@ export class CanvasHost extends BaseHost {
75
75
 
76
76
  constructor(props: CanvasProps, ctx: HostContext) {
77
77
  super("canvas", props, ctx)
78
- this.updateProps(props as Record<string, unknown>)
78
+ this.updateProps(props as unknown as Record<string, unknown>)
79
79
  }
80
80
 
81
81
  measure(maxW: number, maxH: number): Size {
@@ -30,7 +30,7 @@ export class CodeBlockHost extends BaseHost {
30
30
 
31
31
  constructor(props: CodeBlockProps, ctx: HostContext) {
32
32
  super("codeblock", props, ctx)
33
- this.updateProps(props as Record<string, unknown>)
33
+ this.updateProps(props as unknown as Record<string, unknown>)
34
34
  }
35
35
 
36
36
  private computeGutterWidth(): number {
@@ -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 { type FlexAlignment, type FlexAxis, layoutFlex, measureFlex, resolveBgStyle } from "../utils/index.js"
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
 
@@ -46,7 +57,7 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
46
57
  ) {
47
58
  super(elementType, props, ctx)
48
59
  this.alignment = defaultAlignment
49
- this.updateProps(props as Record<string, unknown>)
60
+ this.updateProps(props as unknown as Record<string, unknown>)
50
61
  }
51
62
 
52
63
  /** Get children excluding __static nodes (which are rendered separately) */
@@ -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, constrained.w, constrained.h)
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(result.totalSize)
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
- this.rect,
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
  }
@@ -23,7 +23,7 @@ export class OverlayItemHost extends SingleChildHost {
23
23
 
24
24
  constructor(props: OverlayItemProps, ctx: HostContext) {
25
25
  super("overlayItem", props, ctx)
26
- this.updateProps(props as Record<string, unknown>)
26
+ this.updateProps(props as unknown as Record<string, unknown>)
27
27
  }
28
28
 
29
29
  override measure(maxW: number, maxH: number): Size {
@@ -64,7 +64,7 @@ export class ScrollHost extends SingleChildHost {
64
64
 
65
65
  constructor(props: ScrollProps, ctx: HostContext) {
66
66
  super("scroll", props, ctx)
67
- this.updateProps(props as Record<string, unknown>)
67
+ this.updateProps(props as unknown as Record<string, unknown>)
68
68
  }
69
69
 
70
70
  measure(maxW: number, maxH: number): Size {
@@ -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 (for useScroll to sync its state)
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 { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
195
- if (bgValue !== undefined) {
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, () => {
@@ -18,7 +18,7 @@ export class SpacerHost extends BaseHost {
18
18
 
19
19
  constructor(props: SpacerProps, ctx: HostContext) {
20
20
  super("spacer", props, ctx)
21
- this.updateProps(props as Record<string, unknown>)
21
+ this.updateProps(props as unknown as Record<string, unknown>)
22
22
  }
23
23
 
24
24
  measure(_maxW: number, _maxH: number): Size {