@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.11.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.11.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
- // 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,
@@ -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
 
@@ -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
  }
@@ -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, () => {
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