Fix: Use persistedFocusID for stable focus in Slider, Stepper, Button, RadioButton

All focusable views now use persistedFocusID from StateStorage instead of
generating a new focusID on each render. This ensures focus tracking
remains stable across re-renders.

- Slider: Use persistedFocusID, dim unfocused arrows (50% opacity)
- Stepper: Use persistedFocusID, dim unfocused arrows (50% opacity)
- Button: Consistent focus indicator with persistedFocusID
- RadioButton: Add pulsing focus indicator when focused
This commit is contained in:
phranck
2026-02-13 16:18:13 +01:00
parent ee25db7202
commit 0131e2e7f2
4 changed files with 59 additions and 51 deletions
+13 -3
View File
@@ -272,19 +272,29 @@ private struct _ButtonCore: View, Renderable {
}
func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Get focus manager from environment
let focusManager = context.environment.focusManager
let stateStorage = context.tuiContext.stateStorage
// Get or create persistent focusID from state storage.
// focusID must be stable across renders for focus state to persist.
let focusIDKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
let focusIDBox: StateBox<String> = stateStorage.storage(
for: focusIDKey,
default: focusID
)
let persistedFocusID = focusIDBox.value
// Register this button with the focus manager
let handler = ActionHandler(
focusID: focusID,
focusID: persistedFocusID,
action: action,
canBeFocused: !isDisabled
)
focusManager.register(handler, inSection: context.activeFocusSectionID)
stateStorage.markActive(context.identity)
// Determine if focused
let isFocused = focusManager.isFocused(id: focusID)
let isFocused = focusManager.isFocused(id: persistedFocusID)
let currentStyle = isFocused ? focusedStyle : style
let palette = context.environment.palette
let isPlainStyle = currentStyle.horizontalPadding == 0 && style.foregroundColor == nil && !style.isBold
+9 -13
View File
@@ -328,21 +328,17 @@ private struct _RadioButtonGroupCore<Value: Hashable>: View, Renderable {
// Determine indicator color based on state
let indicatorColor: Color
if isDisabled {
indicatorColor = palette.foregroundTertiary
} else if isSelected {
// Selected: accent color, pulses if group has focus
if groupHasFocus {
let dimAccent = palette.accent.opacity(0.35)
indicatorColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase)
} else {
indicatorColor = palette.accent
}
indicatorColor = palette.foregroundTertiary.opacity(0.5)
} else if isFocused {
// Focused but not selected: dimmed accent (static, no pulse)
indicatorColor = palette.accent.opacity(0.5)
// Focused: pulsing accent (whether selected or not)
let dimAccent = palette.accent.opacity(0.35)
indicatorColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase)
} else if isSelected {
// Selected but not focused: solid accent
indicatorColor = palette.accent
} else {
// Unselected and unfocused: tertiary (dimmed)
indicatorColor = palette.foregroundTertiary
// Unselected and unfocused: dimmed
indicatorColor = palette.foregroundTertiary.opacity(0.5)
}
let styledIndicator = ANSIRenderer.colorize(indicator, foreground: indicatorColor)
+20 -20
View File
@@ -265,28 +265,35 @@ private struct _SliderCore<Label: View, ValueLabel: View>: View, Renderable {
let palette = context.environment.palette
// Determine track width: use available width minus arrows and value label space
// Layout: [label] ❙ ◀ [track] ▶ ❙ [value]
// Focus indicators: 2 chars each side (❙ + space)
// Layout: ◀ [track] ▶ [value]
// Arrows: 2 chars each (◀ + space, space + ▶)
// Value label: ~5 chars (e.g., "100%")
let arrowsWidth = 4 // "◀ " + " ▶"
let focusWidth = 4 // "❙ " on each side when focused (or " " when not)
let valueLabelWidth = 6 // " 100%"
let valueLabelWidth = 5 // " 100%"
let trackWidth: Int
if context.hasExplicitWidth {
let availableForTrack = context.availableWidth - arrowsWidth - focusWidth - valueLabelWidth
let availableForTrack = context.availableWidth - arrowsWidth - valueLabelWidth
trackWidth = max(5, availableForTrack)
} else {
trackWidth = defaultTrackWidth
}
// Get or create persistent focusID from state storage.
// focusID must be stable across renders for focus state to persist.
let focusIDKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 1)
let focusIDBox: StateBox<String> = stateStorage.storage(
for: focusIDKey,
default: focusID
)
let persistedFocusID = focusIDBox.value
// Get or create persistent handler from state storage
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
let handlerBox: StateBox<SliderHandler<Double>> = stateStorage.storage(
for: handlerKey,
default: SliderHandler(
focusID: focusID,
focusID: persistedFocusID,
value: value,
bounds: bounds,
step: step,
@@ -306,7 +313,7 @@ private struct _SliderCore<Label: View, ValueLabel: View>: View, Renderable {
stateStorage.markActive(context.identity)
// Determine focus state
let isFocused = focusManager.isFocused(id: focusID)
let isFocused = focusManager.isFocused(id: persistedFocusID)
// Calculate fraction
let range = bounds.upperBound - bounds.lowerBound
@@ -332,16 +339,17 @@ private struct _SliderCore<Label: View, ValueLabel: View>: View, Renderable {
pulsePhase: Double,
trackWidth: Int
) -> String {
// Arrow colors
// Arrow colors: pulsing accent when focused, dimmed when unfocused
let arrowColor: Color
if isDisabled {
arrowColor = palette.foregroundTertiary
arrowColor = palette.foregroundTertiary.opacity(0.5)
} else if isFocused {
// Pulse between 35% and 100% accent
let dimAccent = palette.accent.opacity(0.35)
arrowColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
} else {
arrowColor = palette.foregroundTertiary
// Dimmed arrows when unfocused
arrowColor = palette.foregroundTertiary.opacity(0.5)
}
// Build track
@@ -364,15 +372,7 @@ private struct _SliderCore<Label: View, ValueLabel: View>: View, Renderable {
let valueLabelColor = isDisabled ? palette.foregroundTertiary : palette.foregroundSecondary
let valueLabel = ANSIRenderer.colorize(valueText, foreground: valueLabelColor)
// Build with focus indicators
if isFocused && !isDisabled {
let dimAccent = palette.accent.opacity(0.35)
let barColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
let bar = ANSIRenderer.colorize("❙", foreground: barColor)
return "\(bar) \(leftArrow) \(track) \(rightArrow) \(bar) \(valueLabel)"
}
// Unfocused: spaces instead of bars for alignment
return " \(leftArrow) \(track) \(rightArrow) \(valueLabel)"
// Pulsing arrows indicate focus - no extra markers needed
return "\(leftArrow) \(track) \(rightArrow) \(valueLabel)"
}
}
+17 -15
View File
@@ -304,12 +304,21 @@ private struct _StepperCore<Label: View>: View, Renderable {
let stateStorage = context.tuiContext.stateStorage
let palette = context.environment.palette
// Get or create persistent focusID from state storage.
// focusID must be stable across renders for focus state to persist.
let focusIDKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 1)
let focusIDBox: StateBox<String> = stateStorage.storage(
for: focusIDKey,
default: focusID
)
let persistedFocusID = focusIDBox.value
// Get or create persistent handler from state storage
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
let handlerBox: StateBox<StepperHandler<Int>> = stateStorage.storage(
for: handlerKey,
default: StepperHandler(
focusID: focusID,
focusID: persistedFocusID,
value: value,
bounds: bounds,
step: step,
@@ -331,7 +340,7 @@ private struct _StepperCore<Label: View>: View, Renderable {
stateStorage.markActive(context.identity)
// Determine focus state
let isFocused = focusManager.isFocused(id: focusID)
let isFocused = focusManager.isFocused(id: persistedFocusID)
// Build the stepper content
let content = buildContent(
@@ -349,11 +358,11 @@ private struct _StepperCore<Label: View>: View, Renderable {
palette: any Palette,
pulsePhase: Double
) -> String {
// Arrow and value colors
// Arrow and value colors: pulsing accent when focused, dimmed when unfocused
let arrowColor: Color
let valueColor: Color
if isDisabled {
arrowColor = palette.foregroundTertiary
arrowColor = palette.foregroundTertiary.opacity(0.5)
valueColor = palette.foregroundTertiary
} else if isFocused {
// Pulse between 35% and 100% accent
@@ -361,7 +370,8 @@ private struct _StepperCore<Label: View>: View, Renderable {
arrowColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
valueColor = palette.foreground
} else {
arrowColor = palette.foregroundTertiary
// Dimmed arrows when unfocused
arrowColor = palette.foregroundTertiary.opacity(0.5)
valueColor = palette.foregroundSecondary
}
@@ -372,15 +382,7 @@ private struct _StepperCore<Label: View>: View, Renderable {
// Build value display
let valueText = ANSIRenderer.colorize(" \(value.wrappedValue) ", foreground: valueColor)
// Build with focus indicators
if isFocused && !isDisabled {
let dimAccent = palette.accent.opacity(0.35)
let barColor = Color.lerp(dimAccent, palette.accent, phase: pulsePhase)
let bar = ANSIRenderer.colorize("❙", foreground: barColor)
return "\(bar) \(leftArrow)\(valueText)\(rightArrow) \(bar)"
}
// Unfocused: spaces instead of bars for alignment
return " \(leftArrow)\(valueText)\(rightArrow) "
// Pulsing arrows indicate focus - no extra markers needed
return "\(leftArrow)\(valueText)\(rightArrow)"
}
}