mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user