mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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
This commit is contained in:
@@ -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<SelectionValue: Hashable>: 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<AnyHashable?>?
|
||||
var singleSelection: Binding<SelectionValue?>?
|
||||
|
||||
/// Binding for multi-selection mode (Set of IDs).
|
||||
var multiSelection: Binding<Set<AnyHashable>>?
|
||||
var multiSelection: Binding<Set<SelectionValue>>?
|
||||
|
||||
/// 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<T: Hashable>(
|
||||
single: Binding<T?>?,
|
||||
multi: Binding<Set<T>>?
|
||||
) {
|
||||
if let binding = single {
|
||||
singleSelection = Binding<AnyHashable?>(
|
||||
get: { binding.wrappedValue.map { AnyHashable($0) } },
|
||||
set: { newValue in
|
||||
binding.wrappedValue = newValue?.base as? T
|
||||
}
|
||||
)
|
||||
}
|
||||
if let binding = multi {
|
||||
multiSelection = Binding<Set<AnyHashable>>(
|
||||
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:
|
||||
|
||||
@@ -225,7 +225,7 @@ private struct _TableCore<Value: Identifiable & Sendable>: View, Renderable wher
|
||||
|
||||
// Get or create persistent handler
|
||||
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler
|
||||
let handlerBox: StateBox<ItemListHandler> = stateStorage.storage(
|
||||
let handlerBox: StateBox<ItemListHandler<Value.ID>> = stateStorage.storage(
|
||||
for: handlerKey,
|
||||
default: ItemListHandler(
|
||||
focusID: persistedFocusID,
|
||||
@@ -241,10 +241,11 @@ private struct _TableCore<Value: Identifiable & Sendable>: 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()
|
||||
|
||||
@@ -54,7 +54,7 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
|
||||
|
||||
// Get or create persistent handler
|
||||
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) // handler
|
||||
let handlerBox: StateBox<ItemListHandler> = stateStorage.storage(
|
||||
let handlerBox: StateBox<ItemListHandler<SelectionValue>> = stateStorage.storage(
|
||||
for: handlerKey,
|
||||
default: ItemListHandler(
|
||||
focusID: persistedFocusID,
|
||||
@@ -73,22 +73,23 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
|
||||
|
||||
// Build selectableIndices set and itemIDs from typed rows
|
||||
var selectableIndices = Set<Int>()
|
||||
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<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
|
||||
|
||||
private func calculateVisibleRows(
|
||||
rows: [SelectableListRow<SelectionValue>],
|
||||
handler: ItemListHandler,
|
||||
handler: ItemListHandler<SelectionValue>,
|
||||
viewportHeight: Int
|
||||
) -> [(index: Int, row: SelectableListRow<SelectionValue>)] {
|
||||
var result: [(Int, SelectableListRow<SelectionValue>)] = []
|
||||
|
||||
@@ -16,7 +16,7 @@ struct ItemListHandlerNavigationTests {
|
||||
|
||||
@Test("Down arrow moves focus forward")
|
||||
func moveDownSimple() {
|
||||
let handler = ItemListHandler(
|
||||
let handler = ItemListHandler<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<AnyHashable> = []
|
||||
let handler = ItemListHandler(
|
||||
var selected: Set<String> = []
|
||||
let handler = ItemListHandler<String>(
|
||||
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> = [AnyHashable("b")]
|
||||
let handler = ItemListHandler(
|
||||
var selected: Set<String> = ["b"]
|
||||
let handler = ItemListHandler<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
focusID: "test",
|
||||
itemCount: 5,
|
||||
viewportHeight: 10,
|
||||
|
||||
Reference in New Issue
Block a user