From 6fee5aa6714c4fc26e05fe989d8587daa92ab25e Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 6 Feb 2026 18:06:08 +0100 Subject: [PATCH] Refactor: RadioButtonGroup focus behavior and orientation-aware navigation - Radio button indicator no longer pulses, stays static (selected=accent, unselected=border) - Only focused item shows indicator, not all items - Vertical orientation: Up/Down arrows navigate, Left/Right ignored - Horizontal orientation: Left/Right arrows navigate, Up/Down ignored - Enter/Space always select regardless of orientation - Pass orientation to RadioButtonGroupHandler for navigation logic - Updated all handler tests to include orientation parameter - All 591 tests passing --- Sources/TUIkit/Views/RadioButton.swift | 24 ++++++++++++++++++------ Tests/TUIkitTests/RadioButtonTests.swift | 16 ++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/TUIkit/Views/RadioButton.swift b/Sources/TUIkit/Views/RadioButton.swift index 68c32ce..29fc7dc 100644 --- a/Sources/TUIkit/Views/RadioButton.swift +++ b/Sources/TUIkit/Views/RadioButton.swift @@ -164,6 +164,7 @@ final class RadioButtonGroupHandler: Focusable { let focusID: String let selection: Binding let itemValues: [AnyHashable] + let orientation: RadioButtonOrientation var canBeFocused: Bool var focusedIndex: Int = 0 @@ -171,11 +172,13 @@ final class RadioButtonGroupHandler: Focusable { focusID: String, selection: Binding, itemValues: [AnyHashable], + orientation: RadioButtonOrientation, canBeFocused: Bool ) { self.focusID = focusID self.selection = selection self.itemValues = itemValues + self.orientation = orientation self.canBeFocused = canBeFocused // Find current focused index based on selection @@ -191,6 +194,8 @@ extension RadioButtonGroupHandler { func handleKeyEvent(_ event: KeyEvent) -> Bool { switch event.key { case .up: + // Up/Down only work for vertical orientation + guard orientation == .vertical else { return false } if focusedIndex > 0 { focusedIndex -= 1 selection.wrappedValue = itemValues[focusedIndex] @@ -199,6 +204,8 @@ extension RadioButtonGroupHandler { return false case .down: + // Up/Down only work for vertical orientation + guard orientation == .vertical else { return false } if focusedIndex < itemValues.count - 1 { focusedIndex += 1 selection.wrappedValue = itemValues[focusedIndex] @@ -207,6 +214,8 @@ extension RadioButtonGroupHandler { return false case .left: + // Left/Right only work for horizontal orientation + guard orientation == .horizontal else { return false } if focusedIndex > 0 { focusedIndex -= 1 selection.wrappedValue = itemValues[focusedIndex] @@ -215,6 +224,8 @@ extension RadioButtonGroupHandler { return false case .right: + // Left/Right only work for horizontal orientation + guard orientation == .horizontal else { return false } if focusedIndex < itemValues.count - 1 { focusedIndex += 1 selection.wrappedValue = itemValues[focusedIndex] @@ -256,6 +267,7 @@ extension RadioButtonGroup: Renderable { focusID: focusID, selection: erasedSelection, itemValues: itemValues, + orientation: orientation, canBeFocused: !isDisabled ) focusManager.register(handler, inSection: context.activeFocusSectionID) @@ -321,19 +333,19 @@ extension RadioButtonGroup: Renderable { // Radio indicator: ● if selected, ◯ if not let indicator = isSelected ? "●" : "◯" - // Determine indicator color + // Determine indicator color (static, no pulsing) let indicatorColor: Color if isDisabled { indicatorColor = palette.foregroundTertiary - } else if isFocused { - // Subtle pulse on focus - let dimAccent = palette.accent.opacity(0.35) - indicatorColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase) + } else if isSelected { + // Selected indicator uses accent color + indicatorColor = palette.accent } else { + // Unselected indicator uses border color indicatorColor = palette.border } - let styledIndicator = ANSIRenderer.colorize(indicator, foreground: indicatorColor, bold: isFocused && !isDisabled) + let styledIndicator = ANSIRenderer.colorize(indicator, foreground: indicatorColor, bold: isSelected && !isDisabled) // Render label let labelView = item.labelBuilder() diff --git a/Tests/TUIkitTests/RadioButtonTests.swift b/Tests/TUIkitTests/RadioButtonTests.swift index 5196cb0..8e95c3b 100644 --- a/Tests/TUIkitTests/RadioButtonTests.swift +++ b/Tests/TUIkitTests/RadioButtonTests.swift @@ -235,7 +235,7 @@ struct RadioButtonGroupTests { @Suite("RadioButtonGroupHandler Tests") struct RadioButtonGroupHandlerTests { - @Test("Handler handles arrow down to navigate items") + @Test("Handler handles arrow down to navigate items (vertical)") func handleArrowDown() { var selection = AnyHashable("opt1") let binding = Binding( @@ -248,6 +248,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: true ) @@ -259,7 +260,7 @@ struct RadioButtonGroupHandlerTests { #expect(selection == AnyHashable("opt2")) } - @Test("Handler handles arrow up to navigate items") + @Test("Handler handles arrow up to navigate items (vertical)") func handleArrowUp() { var selection = AnyHashable("opt2") let binding = Binding( @@ -272,6 +273,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: true ) handler.focusedIndex = 1 @@ -283,7 +285,7 @@ struct RadioButtonGroupHandlerTests { #expect(handler.focusedIndex == 0) } - @Test("Handler handles arrow right (horizontal navigation)") + @Test("Handler handles arrow right (horizontal only)") func handleArrowRight() { var selection = AnyHashable("a") let binding = Binding( @@ -296,6 +298,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .horizontal, canBeFocused: true ) @@ -307,7 +310,7 @@ struct RadioButtonGroupHandlerTests { #expect(selection == AnyHashable("b")) } - @Test("Handler handles arrow left (horizontal navigation)") + @Test("Handler handles arrow left (horizontal only)") func handleArrowLeft() { var selection = AnyHashable("b") let binding = Binding( @@ -320,6 +323,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .horizontal, canBeFocused: true ) handler.focusedIndex = 1 @@ -345,6 +349,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: true ) handler.focusedIndex = 1 @@ -369,6 +374,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: true ) handler.focusedIndex = 1 @@ -393,6 +399,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: true ) @@ -417,6 +424,7 @@ struct RadioButtonGroupHandlerTests { focusID: "test", selection: binding, itemValues: itemValues, + orientation: .vertical, canBeFocused: false )