From 0131e2e7f20fb87d28eb3460ad065faef8c4f199 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 10 Feb 2026 20:24:16 +0100 Subject: [PATCH] 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 --- Sources/TUIkit/Views/Button.swift | 16 +++++++++-- Sources/TUIkit/Views/RadioButton.swift | 22 ++++++-------- Sources/TUIkit/Views/Slider.swift | 40 +++++++++++++------------- Sources/TUIkit/Views/Stepper.swift | 32 +++++++++++---------- 4 files changed, 59 insertions(+), 51 deletions(-) diff --git a/Sources/TUIkit/Views/Button.swift b/Sources/TUIkit/Views/Button.swift index 409b098..90bc64e 100644 --- a/Sources/TUIkit/Views/Button.swift +++ b/Sources/TUIkit/Views/Button.swift @@ -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 = 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 diff --git a/Sources/TUIkit/Views/RadioButton.swift b/Sources/TUIkit/Views/RadioButton.swift index 2109bda..3781332 100644 --- a/Sources/TUIkit/Views/RadioButton.swift +++ b/Sources/TUIkit/Views/RadioButton.swift @@ -328,21 +328,17 @@ private struct _RadioButtonGroupCore: 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) diff --git a/Sources/TUIkit/Views/Slider.swift b/Sources/TUIkit/Views/Slider.swift index e0f4d67..1cc2357 100644 --- a/Sources/TUIkit/Views/Slider.swift +++ b/Sources/TUIkit/Views/Slider.swift @@ -265,28 +265,35 @@ private struct _SliderCore: 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 = 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> = stateStorage.storage( for: handlerKey, default: SliderHandler( - focusID: focusID, + focusID: persistedFocusID, value: value, bounds: bounds, step: step, @@ -306,7 +313,7 @@ private struct _SliderCore: 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: 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: 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)" } } diff --git a/Sources/TUIkit/Views/Stepper.swift b/Sources/TUIkit/Views/Stepper.swift index 73cb403..68721d7 100644 --- a/Sources/TUIkit/Views/Stepper.swift +++ b/Sources/TUIkit/Views/Stepper.swift @@ -304,12 +304,21 @@ private struct _StepperCore: 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 = 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> = stateStorage.storage( for: handlerKey, default: StepperHandler( - focusID: focusID, + focusID: persistedFocusID, value: value, bounds: bounds, step: step, @@ -331,7 +340,7 @@ private struct _StepperCore: 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: 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: 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: 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)" } }