diff --git a/Sources/TUIkit/Views/RadioButton.swift b/Sources/TUIkit/Views/RadioButton.swift index e4e424a..2109bda 100644 --- a/Sources/TUIkit/Views/RadioButton.swift +++ b/Sources/TUIkit/Views/RadioButton.swift @@ -183,106 +183,31 @@ public struct RadioButtonGroup: View { self.isDisabled = isDisabled } - public var body: Never { - fatalError("RadioButtonGroup renders via Renderable") + public var body: some View { + _RadioButtonGroupCore( + selection: selection, + items: items, + orientation: orientation, + focusID: focusID, + isDisabled: isDisabled + ) } } -// MARK: - Radio Button Handler +// MARK: - Internal Core View -/// Internal handler class for radio button group focus and selection management. -/// -/// Persisted across renders via StateStorage to maintain focusedIndex and enable -/// Tab navigation between radio button groups. -final class RadioButtonGroupHandler: Focusable { - let focusID: String - var selection: Binding - var itemValues: [AnyHashable] +/// Internal view that handles the actual rendering of RadioButtonGroup. +private struct _RadioButtonGroupCore: View, Renderable { + let selection: Binding + let items: [RadioButtonItem] let orientation: RadioButtonOrientation - var canBeFocused: Bool + let focusID: String? + let isDisabled: Bool - /// The currently focused item index within the group. - /// Persisted across renders to maintain focus position. - var focusedIndex: Int = 0 - - init( - 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 - if let currentIndex = itemValues.firstIndex(of: selection.wrappedValue) { - self.focusedIndex = currentIndex - } + var body: Never { + fatalError("_RadioButtonGroupCore renders via Renderable") } -} -// MARK: - Focus Lifecycle - -extension RadioButtonGroupHandler { - func onFocusLost() { - // Reset focusedIndex to the selected item when the group loses focus - if let selectedIndex = itemValues.firstIndex(of: selection.wrappedValue) { - focusedIndex = selectedIndex - } - } -} - -// MARK: - Key Event Handling - -extension RadioButtonGroupHandler { - func handleKeyEvent(_ event: KeyEvent) -> Bool { - switch event.key { - case .up: - // Vertical: navigate focus up (don't change selection); Horizontal: consume but do nothing - if orientation == .vertical { - focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 - } - return true - - case .down: - // Vertical: navigate focus down (don't change selection); Horizontal: consume but do nothing - if orientation == .vertical { - focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 - } - return true - - case .left: - // Horizontal: navigate focus left (don't change selection); Vertical: consume but do nothing - if orientation == .horizontal { - focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 - } - return true - - case .right: - // Horizontal: navigate focus right (don't change selection); Vertical: consume but do nothing - if orientation == .horizontal { - focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 - } - return true - - case .enter, .character(" "): - // Select the currently focused item (make it the selection) - selection.wrappedValue = itemValues[focusedIndex] - return true - - default: - return false - } - } -} - -// MARK: - Radio Button Group Rendering - -extension RadioButtonGroup: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let focusManager = context.environment.focusManager let palette = context.environment.palette @@ -432,6 +357,98 @@ extension RadioButtonGroup: Renderable { } } +// MARK: - Radio Button Handler + +/// Internal handler class for radio button group focus and selection management. +/// +/// Persisted across renders via StateStorage to maintain focusedIndex and enable +/// Tab navigation between radio button groups. +final class RadioButtonGroupHandler: Focusable { + let focusID: String + var selection: Binding + var itemValues: [AnyHashable] + let orientation: RadioButtonOrientation + var canBeFocused: Bool + + /// The currently focused item index within the group. + /// Persisted across renders to maintain focus position. + var focusedIndex: Int = 0 + + init( + 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 + if let currentIndex = itemValues.firstIndex(of: selection.wrappedValue) { + self.focusedIndex = currentIndex + } + } +} + +// MARK: - Focus Lifecycle + +extension RadioButtonGroupHandler { + func onFocusLost() { + // Reset focusedIndex to the selected item when the group loses focus + if let selectedIndex = itemValues.firstIndex(of: selection.wrappedValue) { + focusedIndex = selectedIndex + } + } +} + +// MARK: - Key Event Handling + +extension RadioButtonGroupHandler { + func handleKeyEvent(_ event: KeyEvent) -> Bool { + switch event.key { + case .up: + // Vertical: navigate focus up (don't change selection); Horizontal: consume but do nothing + if orientation == .vertical { + focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 + } + return true + + case .down: + // Vertical: navigate focus down (don't change selection); Horizontal: consume but do nothing + if orientation == .vertical { + focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 + } + return true + + case .left: + // Horizontal: navigate focus left (don't change selection); Vertical: consume but do nothing + if orientation == .horizontal { + focusedIndex = focusedIndex > 0 ? focusedIndex - 1 : itemValues.count - 1 + } + return true + + case .right: + // Horizontal: navigate focus right (don't change selection); Vertical: consume but do nothing + if orientation == .horizontal { + focusedIndex = focusedIndex < itemValues.count - 1 ? focusedIndex + 1 : 0 + } + return true + + case .enter, .character(" "): + // Select the currently focused item (make it the selection) + selection.wrappedValue = itemValues[focusedIndex] + return true + + default: + return false + } + } +} + // MARK: - Radio Button Group Convenience Modifiers extension RadioButtonGroup { diff --git a/plans/open/2026-02-07-view-architecture-refactor.md b/plans/open/2026-02-07-view-architecture-refactor.md index 7458037..e7491af 100644 --- a/plans/open/2026-02-07-view-architecture-refactor.md +++ b/plans/open/2026-02-07-view-architecture-refactor.md @@ -283,8 +283,8 @@ Controls that need StateStorage/FocusManager: - [x] Button: Convert to body: some View - [x] Toggle: Convert to body: some View - [x] Menu: Convert to body: some View -- [ ] RadioButtonGroup: Convert to body: some View -- [ ] Tests pass for each +- [x] RadioButtonGroup: Convert to body: some View +- [x] Tests pass for each ### Phase 5: Complex Controls - [x] List: Convert to body: some View