From cbf5b9d5b63b6d0b6d705df4e303d168fbaaa9c4 Mon Sep 17 00:00:00 2001 From: phranck Date: Sat, 14 Feb 2026 17:22:08 +0100 Subject: [PATCH] Refactor: Make ItemListHandler generic over SelectionValue - Replace AnyHashable type erasure with generic SelectionValue parameter - Remove configureSelectionBindings and assign typed bindings directly - Change itemIDs from [AnyHashable] to [SelectionValue?] for nil-safe non-selectable rows - Update _ListCore, _TableCore, and tests to use type-safe selection --- Sources/TUIkit/Focus/ItemListHandler.swift | 50 +++--------- Sources/TUIkit/Views/Table.swift | 9 ++- Sources/TUIkit/Views/_ListCore.swift | 17 +++-- Tests/TUIkitTests/ItemListHandlerTests.swift | 80 ++++++++++---------- 4 files changed, 64 insertions(+), 92 deletions(-) diff --git a/Sources/TUIkit/Focus/ItemListHandler.swift b/Sources/TUIkit/Focus/ItemListHandler.swift index a7f1411f..16958d77 100644 --- a/Sources/TUIkit/Focus/ItemListHandler.swift +++ b/Sources/TUIkit/Focus/ItemListHandler.swift @@ -54,7 +54,7 @@ public enum SelectionMode: Sendable { /// | PageUp | Move up by viewport height | /// | PageDown | Move down by viewport height | /// | Enter/Space | Toggle selection at focused index | -final class ItemListHandler: Focusable { +final class ItemListHandler: Focusable { /// The unique identifier for this focusable element. let focusID: String @@ -77,13 +77,15 @@ final class ItemListHandler: Focusable { var scrollOffset: Int = 0 /// Binding for single selection mode (optional ID). - var singleSelection: Binding? + var singleSelection: Binding? /// Binding for multi-selection mode (Set of IDs). - var multiSelection: Binding>? + var multiSelection: Binding>? /// Maps item indices to their IDs for selection management. - var itemIDs: [AnyHashable] = [] + /// + /// Entries are `nil` for non-selectable rows (e.g. section headers/footers in List). + var itemIDs: [SelectionValue?] = [] /// The set of indices that can be selected and focused. /// @@ -282,40 +284,9 @@ extension ItemListHandler { extension ItemListHandler { /// Toggles the selection state at the focused index. - - /// Configures selection bindings by converting typed bindings to type-erased `AnyHashable` bindings. - /// - /// This helper eliminates duplicated binding conversion code in `_ListCore` and `_TableCore`. - /// - /// - Parameters: - /// - single: An optional single-selection binding. - /// - multi: An optional multi-selection binding. - func configureSelectionBindings( - single: Binding?, - multi: Binding>? - ) { - if let binding = single { - singleSelection = Binding( - get: { binding.wrappedValue.map { AnyHashable($0) } }, - set: { newValue in - binding.wrappedValue = newValue?.base as? T - } - ) - } - if let binding = multi { - multiSelection = Binding>( - get: { Set(binding.wrappedValue.map { AnyHashable($0) }) }, - set: { newValue in - binding.wrappedValue = Set(newValue.compactMap { $0.base as? T }) - } - ) - } - } - func toggleSelectionAtFocusedIndex() { - guard focusedIndex >= 0 && focusedIndex < itemIDs.count else { return } - - let itemID = itemIDs[focusedIndex] + guard focusedIndex >= 0 && focusedIndex < itemIDs.count, + let itemID = itemIDs[focusedIndex] else { return } switch selectionMode { case .single: @@ -344,9 +315,8 @@ extension ItemListHandler { /// - Parameter index: The item index. /// - Returns: True if the item is selected. func isSelected(at index: Int) -> Bool { - guard index >= 0 && index < itemIDs.count else { return false } - - let itemID = itemIDs[index] + guard index >= 0 && index < itemIDs.count, + let itemID = itemIDs[index] else { return false } switch selectionMode { case .single: diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift index 06ef4df7..539f30ad 100644 --- a/Sources/TUIkit/Views/Table.swift +++ b/Sources/TUIkit/Views/Table.swift @@ -225,7 +225,7 @@ private struct _TableCore: View, Renderable wher // Get or create persistent handler let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler - let handlerBox: StateBox = stateStorage.storage( + let handlerBox: StateBox> = stateStorage.storage( for: handlerKey, default: ItemListHandler( focusID: persistedFocusID, @@ -241,10 +241,11 @@ private struct _TableCore: View, Renderable wher handler.itemCount = data.count handler.viewportHeight = viewportHeight handler.canBeFocused = !isDisabled - handler.itemIDs = data.map { AnyHashable($0.id) } + handler.itemIDs = data.map { $0.id } - // Set up selection bindings - handler.configureSelectionBindings(single: singleSelection, multi: multiSelection) + // Assign selection bindings directly (type-safe, no AnyHashable conversion) + handler.singleSelection = singleSelection + handler.multiSelection = multiSelection // Ensure focused item is visible handler.ensureFocusedItemVisible() diff --git a/Sources/TUIkit/Views/_ListCore.swift b/Sources/TUIkit/Views/_ListCore.swift index 74419712..d988ac66 100644 --- a/Sources/TUIkit/Views/_ListCore.swift +++ b/Sources/TUIkit/Views/_ListCore.swift @@ -54,7 +54,7 @@ struct _ListCore = stateStorage.storage( + let handlerBox: StateBox> = stateStorage.storage( for: handlerKey, default: ItemListHandler( focusID: persistedFocusID, @@ -73,22 +73,23 @@ struct _ListCore() - var itemIDs: [AnyHashable] = [] + var itemIDs: [SelectionValue?] = [] for (index, row) in rows.enumerated() { if let id = row.id { // Content row: use actual ID - itemIDs.append(AnyHashable(id)) + itemIDs.append(id) selectableIndices.insert(index) } else { - // Header/footer: use index as placeholder (never selected) - itemIDs.append(AnyHashable(index)) + // Header/footer: nil (non-selectable) + itemIDs.append(nil) } } handler.itemIDs = itemIDs handler.selectableIndices = selectableIndices - // Set up selection bindings - handler.configureSelectionBindings(single: singleSelection, multi: multiSelection) + // Assign selection bindings directly (type-safe, no AnyHashable conversion) + handler.singleSelection = singleSelection + handler.multiSelection = multiSelection // Ensure focused item is visible handler.ensureFocusedItemVisible() @@ -285,7 +286,7 @@ struct _ListCore], - handler: ItemListHandler, + handler: ItemListHandler, viewportHeight: Int ) -> [(index: Int, row: SelectableListRow)] { var result: [(Int, SelectableListRow)] = [] diff --git a/Tests/TUIkitTests/ItemListHandlerTests.swift b/Tests/TUIkitTests/ItemListHandlerTests.swift index 29c3e835..444737ea 100644 --- a/Tests/TUIkitTests/ItemListHandlerTests.swift +++ b/Tests/TUIkitTests/ItemListHandlerTests.swift @@ -16,7 +16,7 @@ struct ItemListHandlerNavigationTests { @Test("Down arrow moves focus forward") func moveDownSimple() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 5, viewportHeight: 3, @@ -32,7 +32,7 @@ struct ItemListHandlerNavigationTests { @Test("Up arrow moves focus backward") func moveUpSimple() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 5, viewportHeight: 3, @@ -49,7 +49,7 @@ struct ItemListHandlerNavigationTests { @Test("Down arrow wraps to start at end") func wrapDownToStart() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, @@ -65,7 +65,7 @@ struct ItemListHandlerNavigationTests { @Test("Up arrow wraps to end at start") func wrapUpToEnd() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, @@ -81,7 +81,7 @@ struct ItemListHandlerNavigationTests { @Test("Home key jumps to first item") func homeJumpsToFirst() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 5, @@ -98,7 +98,7 @@ struct ItemListHandlerNavigationTests { @Test("End key jumps to last item") func endJumpsToLast() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 5, @@ -115,7 +115,7 @@ struct ItemListHandlerNavigationTests { @Test("PageDown moves by viewport height") func pageDownMovesViewport() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 20, viewportHeight: 5, @@ -132,7 +132,7 @@ struct ItemListHandlerNavigationTests { @Test("PageUp moves by viewport height") func pageUpMovesViewport() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 20, viewportHeight: 5, @@ -149,7 +149,7 @@ struct ItemListHandlerNavigationTests { @Test("PageDown clamps at end without wrapping") func pageDownClampsAtEnd() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 5, @@ -165,7 +165,7 @@ struct ItemListHandlerNavigationTests { @Test("PageUp clamps at start without wrapping") func pageUpClampsAtStart() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 5, @@ -181,7 +181,7 @@ struct ItemListHandlerNavigationTests { @Test("Empty list handles navigation gracefully") func emptyListNavigation() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 0, viewportHeight: 5, @@ -204,14 +204,14 @@ struct ItemListHandlerSelectionTests { @Test("Enter toggles single selection") func enterTogglesSingle() { - var selectedID: AnyHashable? - let handler = ItemListHandler( + var selectedID: String? + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .single ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.singleSelection = Binding( get: { selectedID }, set: { selectedID = $0 } @@ -222,19 +222,19 @@ struct ItemListHandlerSelectionTests { let handled = handler.handleKeyEvent(event) #expect(handled == true) - #expect(selectedID == AnyHashable("b")) + #expect(selectedID == "b") } @Test("Space toggles single selection") func spaceTogglesSingle() { - var selectedID: AnyHashable? - let handler = ItemListHandler( + var selectedID: String? + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .single ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.singleSelection = Binding( get: { selectedID }, set: { selectedID = $0 } @@ -245,19 +245,19 @@ struct ItemListHandlerSelectionTests { let handled = handler.handleKeyEvent(event) #expect(handled == true) - #expect(selectedID == AnyHashable("c")) + #expect(selectedID == "c") } @Test("Single selection can be deselected by selecting again") func singleDeselect() { - var selectedID: AnyHashable? = AnyHashable("a") - let handler = ItemListHandler( + var selectedID: String? = "a" + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .single ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.singleSelection = Binding( get: { selectedID }, set: { selectedID = $0 } @@ -272,14 +272,14 @@ struct ItemListHandlerSelectionTests { @Test("Multi selection adds to set") func multiSelectionAdds() { - var selected: Set = [] - let handler = ItemListHandler( + var selected: Set = [] + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .multi ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.multiSelection = Binding( get: { selected }, set: { selected = $0 } @@ -289,20 +289,20 @@ struct ItemListHandlerSelectionTests { let event = KeyEvent(key: .enter) _ = handler.handleKeyEvent(event) - #expect(selected.contains(AnyHashable("b"))) + #expect(selected.contains("b")) #expect(selected.count == 1) } @Test("Multi selection toggles items") func multiSelectionToggles() { - var selected: Set = [AnyHashable("b")] - let handler = ItemListHandler( + var selected: Set = ["b"] + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .multi ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.multiSelection = Binding( get: { selected }, set: { selected = $0 } @@ -312,20 +312,20 @@ struct ItemListHandlerSelectionTests { let event = KeyEvent(key: .enter) _ = handler.handleKeyEvent(event) - #expect(!selected.contains(AnyHashable("b"))) // Removed + #expect(!selected.contains("b")) // Removed #expect(selected.isEmpty) } @Test("isSelected returns correct state") func isSelectedReturnsCorrectState() { - var selectedID: AnyHashable? = AnyHashable("b") - let handler = ItemListHandler( + var selectedID: String? = "b" + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, selectionMode: .single ) - handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")] + handler.itemIDs = ["a", "b", "c"] handler.singleSelection = Binding( get: { selectedID }, set: { selectedID = $0 } @@ -338,7 +338,7 @@ struct ItemListHandlerSelectionTests { @Test("isFocused returns correct state") func isFocusedReturnsCorrectState() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 3, viewportHeight: 3, @@ -360,7 +360,7 @@ struct ItemListHandlerScrollTests { @Test("Scroll offset adjusts when focus moves below viewport") func scrollDownOnFocusBelowViewport() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 3, @@ -374,7 +374,7 @@ struct ItemListHandlerScrollTests { @Test("Scroll offset adjusts when focus moves above viewport") func scrollUpOnFocusAboveViewport() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 3, @@ -389,7 +389,7 @@ struct ItemListHandlerScrollTests { @Test("hasContentAbove returns correct state") func hasContentAboveState() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 3, @@ -405,7 +405,7 @@ struct ItemListHandlerScrollTests { @Test("hasContentBelow returns correct state") func hasContentBelowState() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 3, @@ -421,7 +421,7 @@ struct ItemListHandlerScrollTests { @Test("visibleRange returns correct range") func visibleRangeCorrect() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 10, viewportHeight: 3, @@ -435,7 +435,7 @@ struct ItemListHandlerScrollTests { @Test("visibleRange clamps to item count") func visibleRangeClampsToItemCount() { - let handler = ItemListHandler( + let handler = ItemListHandler( focusID: "test", itemCount: 5, viewportHeight: 10,