mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
Feat: Add List component with ItemListHandler
- ItemListHandler: Shared navigation/selection logic for List and Table - Keyboard navigation (Up/Down/Home/End/PageUp/PageDown) - Single and multi-selection modes via Binding - Scroll offset management with auto-scroll - Focus lifecycle hooks (onFocusLost/onFocusReceived) - List: SwiftUI-compatible scrollable list component - Single selection: List(selection: Binding<ID?>) - Multi-selection: List(selection: Binding<Set<ID>>) - ForEach content via ListRowExtractor protocol - Multi-line row support - Visual states (focused/selected with pulsing accent) - Scroll indicators when content overflows - Empty state placeholder - .disabled() modifier support - ListPage: Example with single and multi-selection demos - 32 new tests (24 handler + 8 list rendering)
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ItemListHandler.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// License: MIT
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Selection Mode
|
||||
|
||||
/// The selection mode for a list or table component.
|
||||
public enum SelectionMode: Sendable {
|
||||
/// Single selection with optional binding (nil = no selection).
|
||||
case single
|
||||
|
||||
/// Multi-selection with Set binding.
|
||||
case multi
|
||||
}
|
||||
|
||||
// MARK: - Item List Handler
|
||||
|
||||
/// A reusable focus handler for list and table components.
|
||||
///
|
||||
/// `ItemListHandler` consolidates the navigation and selection logic shared by
|
||||
/// `List` and `Table`. It handles:
|
||||
/// - Focus registration with the focus manager
|
||||
/// - Keyboard navigation (Up/Down/Home/End/PageUp/PageDown)
|
||||
/// - Single and multi-selection modes
|
||||
/// - Scroll offset management to keep the focused item visible
|
||||
/// - Disabled state (prevents focus when disabled)
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// // In List's renderToBuffer:
|
||||
/// let handler = ItemListHandler(
|
||||
/// focusID: focusID,
|
||||
/// itemCount: items.count,
|
||||
/// viewportHeight: visibleRows,
|
||||
/// selectionMode: .single,
|
||||
/// canBeFocused: !isDisabled
|
||||
/// )
|
||||
/// handler.singleSelection = singleSelectionBinding
|
||||
/// focusManager.register(handler, inSection: sectionID)
|
||||
/// ```
|
||||
///
|
||||
/// ## Navigation Keys
|
||||
///
|
||||
/// | Key | Action |
|
||||
/// |-----|--------|
|
||||
/// | Up | Move focus up (wrap to end) |
|
||||
/// | Down | Move focus down (wrap to start) |
|
||||
/// | Home | Jump to first item |
|
||||
/// | End | Jump to last item |
|
||||
/// | PageUp | Move up by viewport height |
|
||||
/// | PageDown | Move down by viewport height |
|
||||
/// | Enter/Space | Toggle selection at focused index |
|
||||
final class ItemListHandler: Focusable {
|
||||
/// The unique identifier for this focusable element.
|
||||
let focusID: String
|
||||
|
||||
/// The total number of items in the list.
|
||||
var itemCount: Int
|
||||
|
||||
/// The number of visible items in the viewport.
|
||||
var viewportHeight: Int
|
||||
|
||||
/// The selection mode (single or multi).
|
||||
let selectionMode: SelectionMode
|
||||
|
||||
/// Whether this element can currently receive focus.
|
||||
var canBeFocused: Bool
|
||||
|
||||
/// The currently focused item index (keyboard cursor).
|
||||
var focusedIndex: Int = 0
|
||||
|
||||
/// The scroll offset (first visible item index).
|
||||
var scrollOffset: Int = 0
|
||||
|
||||
/// Binding for single selection mode (optional ID).
|
||||
var singleSelection: Binding<AnyHashable?>?
|
||||
|
||||
/// Binding for multi-selection mode (Set of IDs).
|
||||
var multiSelection: Binding<Set<AnyHashable>>?
|
||||
|
||||
/// Maps item indices to their IDs for selection management.
|
||||
var itemIDs: [AnyHashable] = []
|
||||
|
||||
/// Creates an item list handler.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - focusID: The unique focus identifier.
|
||||
/// - itemCount: The total number of items.
|
||||
/// - viewportHeight: The number of visible items.
|
||||
/// - selectionMode: Single or multi-selection mode.
|
||||
/// - canBeFocused: Whether this element can receive focus.
|
||||
init(
|
||||
focusID: String,
|
||||
itemCount: Int,
|
||||
viewportHeight: Int,
|
||||
selectionMode: SelectionMode,
|
||||
canBeFocused: Bool = true
|
||||
) {
|
||||
self.focusID = focusID
|
||||
self.itemCount = itemCount
|
||||
self.viewportHeight = viewportHeight
|
||||
self.selectionMode = selectionMode
|
||||
self.canBeFocused = canBeFocused
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Focus Lifecycle
|
||||
|
||||
extension ItemListHandler {
|
||||
func onFocusLost() {
|
||||
// When focus is lost, reset focused index to the first selected item
|
||||
// (if any) so that when focus returns, the user sees the selection.
|
||||
switch selectionMode {
|
||||
case .single:
|
||||
if let selection = singleSelection?.wrappedValue,
|
||||
let index = itemIDs.firstIndex(of: selection) {
|
||||
focusedIndex = index
|
||||
}
|
||||
case .multi:
|
||||
if let selection = multiSelection?.wrappedValue,
|
||||
let firstSelected = selection.first,
|
||||
let index = itemIDs.firstIndex(of: firstSelected) {
|
||||
focusedIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure scroll offset keeps focused item visible
|
||||
ensureFocusedItemVisible()
|
||||
}
|
||||
|
||||
func onFocusReceived() {
|
||||
// Ensure the focused item is visible when focus is received
|
||||
ensureFocusedItemVisible()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Event Handling
|
||||
|
||||
extension ItemListHandler {
|
||||
func handleKeyEvent(_ event: KeyEvent) -> Bool {
|
||||
guard itemCount > 0 else { return false }
|
||||
|
||||
switch event.key {
|
||||
case .up:
|
||||
moveFocus(by: -1, wrap: true)
|
||||
return true
|
||||
|
||||
case .down:
|
||||
moveFocus(by: 1, wrap: true)
|
||||
return true
|
||||
|
||||
case .home:
|
||||
focusedIndex = 0
|
||||
ensureFocusedItemVisible()
|
||||
return true
|
||||
|
||||
case .end:
|
||||
focusedIndex = itemCount - 1
|
||||
ensureFocusedItemVisible()
|
||||
return true
|
||||
|
||||
case .pageUp:
|
||||
moveFocus(by: -viewportHeight, wrap: false)
|
||||
return true
|
||||
|
||||
case .pageDown:
|
||||
moveFocus(by: viewportHeight, wrap: false)
|
||||
return true
|
||||
|
||||
case .enter, .character(" "):
|
||||
toggleSelectionAtFocusedIndex()
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Helpers
|
||||
|
||||
extension ItemListHandler {
|
||||
/// Moves focus by the given delta, optionally wrapping around.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - delta: The number of items to move (negative = up, positive = down).
|
||||
/// - wrap: Whether to wrap around at boundaries.
|
||||
func moveFocus(by delta: Int, wrap: Bool) {
|
||||
guard itemCount > 0 else { return }
|
||||
|
||||
let newIndex = focusedIndex + delta
|
||||
|
||||
if wrap {
|
||||
// Wrap around: -1 becomes last, count becomes 0
|
||||
focusedIndex = ((newIndex % itemCount) + itemCount) % itemCount
|
||||
} else {
|
||||
// Clamp to valid range
|
||||
focusedIndex = max(0, min(itemCount - 1, newIndex))
|
||||
}
|
||||
|
||||
ensureFocusedItemVisible()
|
||||
}
|
||||
|
||||
/// Adjusts scroll offset to keep the focused item visible.
|
||||
func ensureFocusedItemVisible() {
|
||||
guard viewportHeight > 0 else { return }
|
||||
|
||||
// If focused item is above the viewport, scroll up
|
||||
if focusedIndex < scrollOffset {
|
||||
scrollOffset = focusedIndex
|
||||
}
|
||||
|
||||
// If focused item is below the viewport, scroll down
|
||||
if focusedIndex >= scrollOffset + viewportHeight {
|
||||
scrollOffset = focusedIndex - viewportHeight + 1
|
||||
}
|
||||
|
||||
// Clamp scroll offset to valid range
|
||||
let maxOffset = max(0, itemCount - viewportHeight)
|
||||
scrollOffset = max(0, min(maxOffset, scrollOffset))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Helpers
|
||||
|
||||
extension ItemListHandler {
|
||||
/// Toggles the selection state at the focused index.
|
||||
func toggleSelectionAtFocusedIndex() {
|
||||
guard focusedIndex >= 0 && focusedIndex < itemIDs.count else { return }
|
||||
|
||||
let itemID = itemIDs[focusedIndex]
|
||||
|
||||
switch selectionMode {
|
||||
case .single:
|
||||
// Single selection: set to this item (or nil if already selected to deselect)
|
||||
if singleSelection?.wrappedValue == itemID {
|
||||
singleSelection?.wrappedValue = nil
|
||||
} else {
|
||||
singleSelection?.wrappedValue = itemID
|
||||
}
|
||||
|
||||
case .multi:
|
||||
// Multi-selection: toggle this item in the set
|
||||
if var current = multiSelection?.wrappedValue {
|
||||
if current.contains(itemID) {
|
||||
current.remove(itemID)
|
||||
} else {
|
||||
current.insert(itemID)
|
||||
}
|
||||
multiSelection?.wrappedValue = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the item at the given index is selected.
|
||||
///
|
||||
/// - 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]
|
||||
|
||||
switch selectionMode {
|
||||
case .single:
|
||||
return singleSelection?.wrappedValue == itemID
|
||||
case .multi:
|
||||
return multiSelection?.wrappedValue.contains(itemID) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the item at the given index is focused.
|
||||
///
|
||||
/// - Parameter index: The item index.
|
||||
/// - Returns: True if the item is focused.
|
||||
func isFocused(at index: Int) -> Bool {
|
||||
focusedIndex == index
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scroll Indicator State
|
||||
|
||||
extension ItemListHandler {
|
||||
/// Whether there is content above the visible viewport.
|
||||
var hasContentAbove: Bool {
|
||||
scrollOffset > 0
|
||||
}
|
||||
|
||||
/// Whether there is content below the visible viewport.
|
||||
var hasContentBelow: Bool {
|
||||
scrollOffset + viewportHeight < itemCount
|
||||
}
|
||||
|
||||
/// The range of visible item indices.
|
||||
var visibleRange: Range<Int> {
|
||||
let start = scrollOffset
|
||||
let end = min(scrollOffset + viewportHeight, itemCount)
|
||||
return start..<end
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// List.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// License: MIT
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - List Row
|
||||
|
||||
/// A single row in a list, containing an ID and rendered content.
|
||||
///
|
||||
/// `ListRow` wraps user-provided content and associates it with an identifier
|
||||
/// for selection tracking. Rows can span multiple lines (multi-line content).
|
||||
struct ListRow<ID: Hashable> {
|
||||
/// The unique identifier for this row.
|
||||
let id: ID
|
||||
|
||||
/// The rendered content buffer for this row.
|
||||
let buffer: FrameBuffer
|
||||
|
||||
/// The height of this row in lines.
|
||||
var height: Int { buffer.height }
|
||||
}
|
||||
|
||||
// MARK: - List (Single Selection)
|
||||
|
||||
/// A scrollable list with keyboard navigation and single selection.
|
||||
///
|
||||
/// `List` displays a vertical collection of items with support for:
|
||||
/// - Keyboard navigation (Up/Down/Home/End/PageUp/PageDown)
|
||||
/// - Single selection via optional binding
|
||||
/// - Scrolling with automatic viewport management
|
||||
/// - Visual states for focused and selected items
|
||||
/// - Multi-line row support
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// @State var selectedID: String?
|
||||
///
|
||||
/// List(selection: $selectedID) {
|
||||
/// ForEach(items) { item in
|
||||
/// Text(item.name)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Visual States
|
||||
///
|
||||
/// | State | Rendering |
|
||||
/// |-------|-----------|
|
||||
/// | Focused + Selected | Pulsing accent, bold |
|
||||
/// | Focused only | Accent foreground (cursor) |
|
||||
/// | Selected only | Dimmed accent |
|
||||
/// | Neither | Default foreground |
|
||||
///
|
||||
/// ## Scroll Indicators
|
||||
///
|
||||
/// When content extends beyond the viewport, scroll indicators (arrows)
|
||||
/// appear at the top and/or bottom edges.
|
||||
public struct List<SelectionValue: Hashable, Content: View>: View {
|
||||
/// The content of the list (typically ForEach).
|
||||
let content: Content
|
||||
|
||||
/// Binding for single selection (optional ID).
|
||||
let singleSelection: Binding<SelectionValue?>?
|
||||
|
||||
/// Binding for multi-selection (Set of IDs).
|
||||
let multiSelection: Binding<Set<SelectionValue>>?
|
||||
|
||||
/// The selection mode derived from which binding is set.
|
||||
var selectionMode: SelectionMode {
|
||||
multiSelection != nil ? .multi : .single
|
||||
}
|
||||
|
||||
/// The unique focus identifier for this list.
|
||||
let focusID: String?
|
||||
|
||||
/// Whether the list is disabled.
|
||||
var isDisabled: Bool
|
||||
|
||||
/// The maximum number of visible rows (nil = use available height).
|
||||
let maxVisibleRows: Int?
|
||||
|
||||
/// The placeholder text shown when the list is empty.
|
||||
let emptyPlaceholder: String
|
||||
|
||||
public var body: Never {
|
||||
fatalError("List renders via Renderable")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Single Selection Initializer
|
||||
|
||||
extension List {
|
||||
/// Creates a list with single selection.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - selection: A binding to the selected item's ID (nil = no selection).
|
||||
/// - focusID: The unique focus identifier (default: auto-generated).
|
||||
/// - maxVisibleRows: Maximum visible rows (default: nil = available height).
|
||||
/// - emptyPlaceholder: Placeholder text when empty (default: "No items").
|
||||
/// - content: A ViewBuilder that defines the list content.
|
||||
public init(
|
||||
selection: Binding<SelectionValue?>,
|
||||
focusID: String? = nil,
|
||||
maxVisibleRows: Int? = nil,
|
||||
emptyPlaceholder: String = "No items",
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.singleSelection = selection
|
||||
self.multiSelection = nil
|
||||
self.focusID = focusID
|
||||
self.isDisabled = false
|
||||
self.maxVisibleRows = maxVisibleRows
|
||||
self.emptyPlaceholder = emptyPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Multi Selection Initializer
|
||||
|
||||
extension List {
|
||||
/// Creates a list with multi-selection.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - selection: A binding to the set of selected item IDs.
|
||||
/// - focusID: The unique focus identifier (default: auto-generated).
|
||||
/// - maxVisibleRows: Maximum visible rows (default: nil = available height).
|
||||
/// - emptyPlaceholder: Placeholder text when empty (default: "No items").
|
||||
/// - content: A ViewBuilder that defines the list content.
|
||||
public init(
|
||||
selection: Binding<Set<SelectionValue>>,
|
||||
focusID: String? = nil,
|
||||
maxVisibleRows: Int? = nil,
|
||||
emptyPlaceholder: String = "No items",
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
self.singleSelection = nil
|
||||
self.multiSelection = selection
|
||||
self.focusID = focusID
|
||||
self.isDisabled = false
|
||||
self.maxVisibleRows = maxVisibleRows
|
||||
self.emptyPlaceholder = emptyPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Modifiers
|
||||
|
||||
extension List {
|
||||
/// Creates a disabled version of this list.
|
||||
///
|
||||
/// - Parameter disabled: Whether the list is disabled.
|
||||
/// - Returns: A new list with the disabled state.
|
||||
public func disabled(_ disabled: Bool = true) -> List<SelectionValue, Content> {
|
||||
var copy = self
|
||||
copy.isDisabled = disabled
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
extension List: Renderable {
|
||||
func renderToBuffer(context: RenderContext) -> FrameBuffer {
|
||||
let focusManager = context.environment.focusManager
|
||||
let palette = context.environment.palette
|
||||
let stateStorage = context.tuiContext.stateStorage
|
||||
|
||||
// Extract rows from content
|
||||
let rows = extractRows(from: content, context: context)
|
||||
|
||||
// Handle empty state
|
||||
guard !rows.isEmpty else {
|
||||
return renderEmptyState(palette: palette)
|
||||
}
|
||||
|
||||
// Calculate viewport height
|
||||
let availableHeight = context.availableHeight
|
||||
let viewportHeight = maxVisibleRows ?? max(1, availableHeight - 2) // Reserve 2 lines for indicators
|
||||
|
||||
// Get or create persistent focusID
|
||||
let focusIDKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 1)
|
||||
let focusIDBox: StateBox<String> = stateStorage.storage(
|
||||
for: focusIDKey,
|
||||
default: focusID ?? "list-\(context.identity.path)"
|
||||
)
|
||||
let persistedFocusID = focusIDBox.value
|
||||
|
||||
// Get or create persistent handler
|
||||
let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0)
|
||||
let handlerBox: StateBox<ItemListHandler> = stateStorage.storage(
|
||||
for: handlerKey,
|
||||
default: ItemListHandler(
|
||||
focusID: persistedFocusID,
|
||||
itemCount: rows.count,
|
||||
viewportHeight: viewportHeight,
|
||||
selectionMode: selectionMode,
|
||||
canBeFocused: !isDisabled
|
||||
)
|
||||
)
|
||||
let handler = handlerBox.value
|
||||
|
||||
// Update handler with current values
|
||||
handler.itemCount = rows.count
|
||||
handler.viewportHeight = viewportHeight
|
||||
handler.canBeFocused = !isDisabled
|
||||
handler.itemIDs = rows.map { AnyHashable($0.id) }
|
||||
|
||||
// Set up selection bindings
|
||||
if let binding = singleSelection {
|
||||
handler.singleSelection = Binding<AnyHashable?>(
|
||||
get: { binding.wrappedValue.map { AnyHashable($0) } },
|
||||
set: { newValue in
|
||||
binding.wrappedValue = newValue?.base as? SelectionValue
|
||||
}
|
||||
)
|
||||
}
|
||||
if let binding = multiSelection {
|
||||
handler.multiSelection = Binding<Set<AnyHashable>>(
|
||||
get: { Set(binding.wrappedValue.map { AnyHashable($0) }) },
|
||||
set: { newValue in
|
||||
binding.wrappedValue = Set(newValue.compactMap { $0.base as? SelectionValue })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure focused item is visible
|
||||
handler.ensureFocusedItemVisible()
|
||||
|
||||
// Register with focus manager
|
||||
focusManager.register(handler, inSection: context.activeFocusSectionID)
|
||||
stateStorage.markActive(context.identity)
|
||||
|
||||
// Check if this list has focus
|
||||
let listHasFocus = focusManager.isFocused(id: persistedFocusID)
|
||||
|
||||
// Calculate visible rows based on line positions
|
||||
let visibleRows = calculateVisibleRows(
|
||||
rows: rows,
|
||||
handler: handler,
|
||||
viewportHeight: viewportHeight
|
||||
)
|
||||
|
||||
// Render visible rows
|
||||
var lines: [String] = []
|
||||
|
||||
// Top scroll indicator
|
||||
if handler.hasContentAbove {
|
||||
lines.append(renderScrollIndicator(direction: .up, width: context.availableWidth, palette: palette))
|
||||
}
|
||||
|
||||
// Render each visible row
|
||||
for (rowIndex, row) in visibleRows {
|
||||
let isFocused = handler.isFocused(at: rowIndex) && listHasFocus
|
||||
let isSelected = handler.isSelected(at: rowIndex)
|
||||
|
||||
let styledLines = renderRow(
|
||||
row: row,
|
||||
isFocused: isFocused,
|
||||
isSelected: isSelected,
|
||||
listHasFocus: listHasFocus,
|
||||
context: context,
|
||||
palette: palette
|
||||
)
|
||||
lines.append(contentsOf: styledLines)
|
||||
}
|
||||
|
||||
// Bottom scroll indicator
|
||||
if handler.hasContentBelow {
|
||||
lines.append(renderScrollIndicator(direction: .down, width: context.availableWidth, palette: palette))
|
||||
}
|
||||
|
||||
return FrameBuffer(lines: lines)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row Extraction
|
||||
|
||||
private extension List {
|
||||
/// Extracts ListRows from the content view.
|
||||
func extractRows(from content: Content, context: RenderContext) -> [ListRow<SelectionValue>] {
|
||||
// Check if content provides list row extraction
|
||||
if let extractor = content as? ListRowExtractor {
|
||||
return extractor.extractListRows(context: context)
|
||||
}
|
||||
|
||||
// Check if content provides child infos (TupleView, ViewArray)
|
||||
if let provider = content as? ChildInfoProvider {
|
||||
let infos = provider.childInfos(context: context)
|
||||
return infos.enumerated().compactMap { index, info -> ListRow<SelectionValue>? in
|
||||
guard let buffer = info.buffer else { return nil }
|
||||
// Use index as ID if SelectionValue is Int
|
||||
if let indexID = index as? SelectionValue {
|
||||
return ListRow(id: indexID, buffer: buffer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Single item fallback
|
||||
let buffer = TUIkit.renderToBuffer(content, context: context)
|
||||
if let zeroID = 0 as? SelectionValue {
|
||||
return [ListRow(id: zeroID, buffer: buffer)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Row Extractor Protocol
|
||||
|
||||
/// Protocol for views that can provide list rows with IDs.
|
||||
@MainActor
|
||||
protocol ListRowExtractor {
|
||||
/// Extracts list rows with their associated IDs.
|
||||
func extractListRows<ID: Hashable>(context: RenderContext) -> [ListRow<ID>]
|
||||
}
|
||||
|
||||
extension ForEach: ListRowExtractor {
|
||||
func extractListRows<RowID: Hashable>(context: RenderContext) -> [ListRow<RowID>] {
|
||||
data.compactMap { element -> ListRow<RowID>? in
|
||||
let elementID = element[keyPath: idKeyPath]
|
||||
let view = content(element)
|
||||
let buffer = TUIkit.renderToBuffer(view, context: context)
|
||||
|
||||
// Try to cast the ForEach's ID type to the List's SelectionValue
|
||||
guard let rowID = elementID as? RowID else { return nil }
|
||||
return ListRow(id: rowID, buffer: buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visible Row Calculation
|
||||
|
||||
private extension List {
|
||||
/// Calculates which rows are visible in the viewport, accounting for multi-line rows.
|
||||
func calculateVisibleRows(
|
||||
rows: [ListRow<SelectionValue>],
|
||||
handler: ItemListHandler,
|
||||
viewportHeight: Int
|
||||
) -> [(index: Int, row: ListRow<SelectionValue>)] {
|
||||
var result: [(Int, ListRow<SelectionValue>)] = []
|
||||
var linesUsed = 0
|
||||
var currentIndex = handler.scrollOffset
|
||||
|
||||
while currentIndex < rows.count && linesUsed < viewportHeight {
|
||||
let row = rows[currentIndex]
|
||||
let rowHeight = row.height
|
||||
|
||||
// Check if this row fits in remaining space
|
||||
if linesUsed + rowHeight <= viewportHeight {
|
||||
result.append((currentIndex, row))
|
||||
linesUsed += rowHeight
|
||||
currentIndex += 1
|
||||
} else {
|
||||
// Partial row: include it but it may be clipped
|
||||
result.append((currentIndex, row))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row Rendering
|
||||
|
||||
private extension List {
|
||||
/// Renders a single row with appropriate styling.
|
||||
func renderRow(
|
||||
row: ListRow<SelectionValue>,
|
||||
isFocused: Bool,
|
||||
isSelected: Bool,
|
||||
listHasFocus: Bool,
|
||||
context: RenderContext,
|
||||
palette: any Palette
|
||||
) -> [String] {
|
||||
// Determine the row indicator and colors
|
||||
let indicator: String
|
||||
let foregroundColor: Color
|
||||
|
||||
if isFocused && isSelected {
|
||||
// Focused + Selected: pulsing accent
|
||||
let dimAccent = palette.accent.opacity(0.35)
|
||||
foregroundColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase)
|
||||
indicator = "●"
|
||||
} else if isFocused {
|
||||
// Focused only: accent (navigation cursor)
|
||||
foregroundColor = palette.accent
|
||||
indicator = "›"
|
||||
} else if isSelected {
|
||||
// Selected only: dimmed accent
|
||||
foregroundColor = palette.accent.opacity(0.6)
|
||||
indicator = "●"
|
||||
} else {
|
||||
// Neither: default
|
||||
foregroundColor = palette.foreground
|
||||
indicator = " "
|
||||
}
|
||||
|
||||
// Style the indicator
|
||||
let styledIndicator = ANSIRenderer.colorize(
|
||||
indicator,
|
||||
foreground: foregroundColor,
|
||||
bold: isFocused
|
||||
)
|
||||
|
||||
// Add indicator to each line of the row
|
||||
return row.buffer.lines.enumerated().map { lineIndex, line in
|
||||
if lineIndex == 0 {
|
||||
// First line gets the indicator
|
||||
return styledIndicator + " " + line
|
||||
} else {
|
||||
// Continuation lines get padding
|
||||
return " " + line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scroll Indicators
|
||||
|
||||
private extension List {
|
||||
enum ScrollDirection {
|
||||
case up, down
|
||||
}
|
||||
|
||||
func renderScrollIndicator(direction: ScrollDirection, width: Int, palette: any Palette) -> String {
|
||||
let arrow = direction == .up ? "▲" : "▼"
|
||||
let label = direction == .up ? " more above " : " more below "
|
||||
|
||||
let styledArrow = ANSIRenderer.colorize(arrow, foreground: palette.foregroundTertiary)
|
||||
let styledLabel = ANSIRenderer.colorize(label, foreground: palette.foregroundTertiary)
|
||||
|
||||
// Center the indicator
|
||||
let indicatorWidth = 1 + label.count
|
||||
let padding = max(0, (width - indicatorWidth) / 2)
|
||||
|
||||
return String(repeating: " ", count: padding) + styledArrow + styledLabel
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private extension List {
|
||||
func renderEmptyState(palette: any Palette) -> FrameBuffer {
|
||||
let styledText = ANSIRenderer.colorize(
|
||||
emptyPlaceholder,
|
||||
foreground: palette.foregroundTertiary
|
||||
)
|
||||
return FrameBuffer(lines: [styledText])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ enum DemoPage: Int, CaseIterable {
|
||||
case toggles
|
||||
case radioButtons
|
||||
case spinners
|
||||
case lists
|
||||
}
|
||||
|
||||
// MARK: - Content View (Page Router)
|
||||
@@ -57,6 +58,10 @@ struct ContentView: View {
|
||||
// Quick jump to Spinners
|
||||
currentPage = .spinners
|
||||
return true
|
||||
case .character("0"):
|
||||
// Quick jump to Lists
|
||||
currentPage = .lists
|
||||
return true
|
||||
default:
|
||||
return false // Let other handlers process
|
||||
}
|
||||
@@ -99,6 +104,9 @@ struct ContentView: View {
|
||||
case .spinners:
|
||||
SpinnersPage()
|
||||
.statusBarItems(subPageItems(pageSetter: pageSetter))
|
||||
case .lists:
|
||||
ListPage()
|
||||
.statusBarItems(subPageItems(pageSetter: pageSetter))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ListPage.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// License: MIT
|
||||
|
||||
import TUIkit
|
||||
|
||||
// MARK: - Demo Item
|
||||
|
||||
/// A simple item for list demos.
|
||||
private struct FileItem: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let size: String
|
||||
let icon: String
|
||||
|
||||
static let sampleFiles: [FileItem] = [
|
||||
FileItem(id: "1", name: "README.md", size: "4.2 KB", icon: "📄"),
|
||||
FileItem(id: "2", name: "Package.swift", size: "1.8 KB", icon: "📦"),
|
||||
FileItem(id: "3", name: "Sources", size: "128 KB", icon: "📁"),
|
||||
FileItem(id: "4", name: "Tests", size: "64 KB", icon: "📁"),
|
||||
FileItem(id: "5", name: ".gitignore", size: "0.5 KB", icon: "📄"),
|
||||
FileItem(id: "6", name: "LICENSE", size: "1.1 KB", icon: "📄"),
|
||||
FileItem(id: "7", name: "docs", size: "256 KB", icon: "📁"),
|
||||
FileItem(id: "8", name: "plans", size: "32 KB", icon: "📁"),
|
||||
FileItem(id: "9", name: ".swiftlint.yml", size: "1.2 KB", icon: "⚙️"),
|
||||
FileItem(id: "10", name: ".github", size: "8 KB", icon: "📁"),
|
||||
FileItem(id: "11", name: "Makefile", size: "0.8 KB", icon: "📄"),
|
||||
FileItem(id: "12", name: ".claude", size: "16 KB", icon: "📁"),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - List Page
|
||||
|
||||
/// List component demo page.
|
||||
///
|
||||
/// Shows interactive list features including:
|
||||
/// - Single selection with binding
|
||||
/// - Multi-selection with binding
|
||||
/// - Keyboard navigation (Up/Down/Home/End/PageUp/PageDown)
|
||||
/// - Scroll indicators
|
||||
/// - Empty state placeholder
|
||||
struct ListPage: View {
|
||||
@State var singleSelection: String?
|
||||
@State var multiSelection: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 1) {
|
||||
|
||||
HStack(spacing: 2) {
|
||||
DemoSection("Single Selection") {
|
||||
List(
|
||||
selection: $singleSelection,
|
||||
maxVisibleRows: 6
|
||||
) {
|
||||
ForEach(FileItem.sampleFiles) { file in
|
||||
HStack(spacing: 1) {
|
||||
Text(file.icon)
|
||||
Text(file.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DemoSection("Multi Selection") {
|
||||
List(
|
||||
selection: $multiSelection,
|
||||
maxVisibleRows: 6
|
||||
) {
|
||||
ForEach(FileItem.sampleFiles) { file in
|
||||
HStack(spacing: 1) {
|
||||
Text(file.icon)
|
||||
Text(file.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DemoSection("Current Selections") {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 1) {
|
||||
Text("Single:").foregroundColor(.palette.foregroundSecondary)
|
||||
Text(singleSelection ?? "(none)")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
}
|
||||
HStack(spacing: 1) {
|
||||
Text("Multi:").foregroundColor(.palette.foregroundSecondary)
|
||||
Text(multiSelection.isEmpty ? "(none)" : multiSelection.sorted().joined(separator: ", "))
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DemoSection("Empty List") {
|
||||
List(selection: Binding<String?>(get: { nil }, set: { _ in })) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
DemoSection("Navigation") {
|
||||
VStack {
|
||||
Text("Use [↑/↓] to navigate items").dim()
|
||||
Text("Use [Home/End] to jump to first/last").dim()
|
||||
Text("Use [PageUp/PageDown] for fast scrolling").dim()
|
||||
Text("Use [Enter/Space] to select/deselect").dim()
|
||||
Text("Use [Tab] to switch between lists").dim()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.appHeader {
|
||||
HStack {
|
||||
Text("List Demo").bold().foregroundColor(.palette.accent)
|
||||
Spacer()
|
||||
Text("TUIkit v\(tuiKitVersion)").foregroundColor(.palette.foregroundTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ struct MainMenuPage: View {
|
||||
MenuItem(label: "Toggles & Checkboxes", shortcut: "7"),
|
||||
MenuItem(label: "Radio Buttons", shortcut: "8"),
|
||||
MenuItem(label: "Spinners", shortcut: "9"),
|
||||
MenuItem(label: "Lists", shortcut: "0"),
|
||||
],
|
||||
selection: $menuSelection,
|
||||
onSelect: { index in
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ItemListHandlerTests.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// License: MIT
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import TUIkit
|
||||
|
||||
// MARK: - Item List Handler Navigation Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("ItemListHandler Navigation Tests")
|
||||
struct ItemListHandlerNavigationTests {
|
||||
|
||||
@Test("Down arrow moves focus forward")
|
||||
func moveDownSimple() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 5,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
|
||||
let event = KeyEvent(key: .down)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 1)
|
||||
}
|
||||
|
||||
@Test("Up arrow moves focus backward")
|
||||
func moveUpSimple() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 5,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 2
|
||||
|
||||
let event = KeyEvent(key: .up)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 1)
|
||||
}
|
||||
|
||||
@Test("Down arrow wraps to start at end")
|
||||
func wrapDownToStart() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 2 // Last item
|
||||
|
||||
let event = KeyEvent(key: .down)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handler.focusedIndex == 0) // Wrapped to first
|
||||
}
|
||||
|
||||
@Test("Up arrow wraps to end at start")
|
||||
func wrapUpToEnd() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 0 // First item
|
||||
|
||||
let event = KeyEvent(key: .up)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handler.focusedIndex == 2) // Wrapped to last
|
||||
}
|
||||
|
||||
@Test("Home key jumps to first item")
|
||||
func homeJumpsToFirst() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 7
|
||||
|
||||
let event = KeyEvent(key: .home)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 0)
|
||||
}
|
||||
|
||||
@Test("End key jumps to last item")
|
||||
func endJumpsToLast() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 2
|
||||
|
||||
let event = KeyEvent(key: .end)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 9)
|
||||
}
|
||||
|
||||
@Test("PageDown moves by viewport height")
|
||||
func pageDownMovesViewport() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 20,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 2
|
||||
|
||||
let event = KeyEvent(key: .pageDown)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 7) // 2 + 5
|
||||
}
|
||||
|
||||
@Test("PageUp moves by viewport height")
|
||||
func pageUpMovesViewport() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 20,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 10
|
||||
|
||||
let event = KeyEvent(key: .pageUp)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(handler.focusedIndex == 5) // 10 - 5
|
||||
}
|
||||
|
||||
@Test("PageDown clamps at end without wrapping")
|
||||
func pageDownClampsAtEnd() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 8
|
||||
|
||||
let event = KeyEvent(key: .pageDown)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handler.focusedIndex == 9) // Clamped to last
|
||||
}
|
||||
|
||||
@Test("PageUp clamps at start without wrapping")
|
||||
func pageUpClampsAtStart() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 2
|
||||
|
||||
let event = KeyEvent(key: .pageUp)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handler.focusedIndex == 0) // Clamped to first
|
||||
}
|
||||
|
||||
@Test("Empty list handles navigation gracefully")
|
||||
func emptyListNavigation() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 0,
|
||||
viewportHeight: 5,
|
||||
selectionMode: .single
|
||||
)
|
||||
|
||||
let event = KeyEvent(key: .down)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == false)
|
||||
#expect(handler.focusedIndex == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item List Handler Selection Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("ItemListHandler Selection Tests")
|
||||
struct ItemListHandlerSelectionTests {
|
||||
|
||||
@Test("Enter toggles single selection")
|
||||
func enterTogglesSingle() {
|
||||
var selectedID: AnyHashable?
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.singleSelection = Binding(
|
||||
get: { selectedID },
|
||||
set: { selectedID = $0 }
|
||||
)
|
||||
handler.focusedIndex = 1
|
||||
|
||||
let event = KeyEvent(key: .enter)
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(selectedID == AnyHashable("b"))
|
||||
}
|
||||
|
||||
@Test("Space toggles single selection")
|
||||
func spaceTogglesSingle() {
|
||||
var selectedID: AnyHashable?
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.singleSelection = Binding(
|
||||
get: { selectedID },
|
||||
set: { selectedID = $0 }
|
||||
)
|
||||
handler.focusedIndex = 2
|
||||
|
||||
let event = KeyEvent(key: .character(" "))
|
||||
let handled = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(handled == true)
|
||||
#expect(selectedID == AnyHashable("c"))
|
||||
}
|
||||
|
||||
@Test("Single selection can be deselected by selecting again")
|
||||
func singleDeselect() {
|
||||
var selectedID: AnyHashable? = AnyHashable("a")
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.singleSelection = Binding(
|
||||
get: { selectedID },
|
||||
set: { selectedID = $0 }
|
||||
)
|
||||
handler.focusedIndex = 0 // Already selected
|
||||
|
||||
let event = KeyEvent(key: .enter)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(selectedID == nil) // Deselected
|
||||
}
|
||||
|
||||
@Test("Multi selection adds to set")
|
||||
func multiSelectionAdds() {
|
||||
var selected: Set<AnyHashable> = []
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .multi
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.multiSelection = Binding(
|
||||
get: { selected },
|
||||
set: { selected = $0 }
|
||||
)
|
||||
handler.focusedIndex = 1
|
||||
|
||||
let event = KeyEvent(key: .enter)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(selected.contains(AnyHashable("b")))
|
||||
#expect(selected.count == 1)
|
||||
}
|
||||
|
||||
@Test("Multi selection toggles items")
|
||||
func multiSelectionToggles() {
|
||||
var selected: Set<AnyHashable> = [AnyHashable("b")]
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .multi
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.multiSelection = Binding(
|
||||
get: { selected },
|
||||
set: { selected = $0 }
|
||||
)
|
||||
handler.focusedIndex = 1 // Already selected
|
||||
|
||||
let event = KeyEvent(key: .enter)
|
||||
_ = handler.handleKeyEvent(event)
|
||||
|
||||
#expect(!selected.contains(AnyHashable("b"))) // Removed
|
||||
#expect(selected.isEmpty)
|
||||
}
|
||||
|
||||
@Test("isSelected returns correct state")
|
||||
func isSelectedReturnsCorrectState() {
|
||||
var selectedID: AnyHashable? = AnyHashable("b")
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.itemIDs = [AnyHashable("a"), AnyHashable("b"), AnyHashable("c")]
|
||||
handler.singleSelection = Binding(
|
||||
get: { selectedID },
|
||||
set: { selectedID = $0 }
|
||||
)
|
||||
|
||||
#expect(handler.isSelected(at: 0) == false)
|
||||
#expect(handler.isSelected(at: 1) == true)
|
||||
#expect(handler.isSelected(at: 2) == false)
|
||||
}
|
||||
|
||||
@Test("isFocused returns correct state")
|
||||
func isFocusedReturnsCorrectState() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 3,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 1
|
||||
|
||||
#expect(handler.isFocused(at: 0) == false)
|
||||
#expect(handler.isFocused(at: 1) == true)
|
||||
#expect(handler.isFocused(at: 2) == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item List Handler Scroll Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("ItemListHandler Scroll Tests")
|
||||
struct ItemListHandlerScrollTests {
|
||||
|
||||
@Test("Scroll offset adjusts when focus moves below viewport")
|
||||
func scrollDownOnFocusBelowViewport() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.focusedIndex = 5
|
||||
handler.ensureFocusedItemVisible()
|
||||
|
||||
#expect(handler.scrollOffset == 3) // 5 - 3 + 1 = 3
|
||||
}
|
||||
|
||||
@Test("Scroll offset adjusts when focus moves above viewport")
|
||||
func scrollUpOnFocusAboveViewport() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.scrollOffset = 5
|
||||
handler.focusedIndex = 2
|
||||
handler.ensureFocusedItemVisible()
|
||||
|
||||
#expect(handler.scrollOffset == 2)
|
||||
}
|
||||
|
||||
@Test("hasContentAbove returns correct state")
|
||||
func hasContentAboveState() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
|
||||
handler.scrollOffset = 0
|
||||
#expect(handler.hasContentAbove == false)
|
||||
|
||||
handler.scrollOffset = 3
|
||||
#expect(handler.hasContentAbove == true)
|
||||
}
|
||||
|
||||
@Test("hasContentBelow returns correct state")
|
||||
func hasContentBelowState() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
|
||||
handler.scrollOffset = 0
|
||||
#expect(handler.hasContentBelow == true)
|
||||
|
||||
handler.scrollOffset = 7 // 7 + 3 = 10 = itemCount
|
||||
#expect(handler.hasContentBelow == false)
|
||||
}
|
||||
|
||||
@Test("visibleRange returns correct range")
|
||||
func visibleRangeCorrect() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 10,
|
||||
viewportHeight: 3,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.scrollOffset = 4
|
||||
|
||||
let range = handler.visibleRange
|
||||
#expect(range == 4..<7)
|
||||
}
|
||||
|
||||
@Test("visibleRange clamps to item count")
|
||||
func visibleRangeClampsToItemCount() {
|
||||
let handler = ItemListHandler(
|
||||
focusID: "test",
|
||||
itemCount: 5,
|
||||
viewportHeight: 10,
|
||||
selectionMode: .single
|
||||
)
|
||||
handler.scrollOffset = 0
|
||||
|
||||
let range = handler.visibleRange
|
||||
#expect(range == 0..<5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||||
// ListTests.swift
|
||||
//
|
||||
// Created by LAYERED.work
|
||||
// License: MIT
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import TUIkit
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@MainActor
|
||||
private func createTestContext(width: Int = 80, height: Int = 24) -> RenderContext {
|
||||
let focusManager = FocusManager()
|
||||
var environment = EnvironmentValues()
|
||||
environment.focusManager = focusManager
|
||||
|
||||
return RenderContext(
|
||||
availableWidth: width,
|
||||
availableHeight: height,
|
||||
environment: environment
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - List Rendering Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("List Rendering Tests")
|
||||
struct ListRenderingTests {
|
||||
|
||||
@Test("Empty list shows placeholder")
|
||||
func emptyListPlaceholder() {
|
||||
let context = createTestContext()
|
||||
|
||||
var selection: String?
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
let buffer = renderToBuffer(list, context: context)
|
||||
let content = buffer.lines.joined()
|
||||
|
||||
#expect(content.contains("No items"))
|
||||
}
|
||||
|
||||
@Test("Custom empty placeholder is shown")
|
||||
func customEmptyPlaceholder() {
|
||||
let context = createTestContext()
|
||||
|
||||
var selection: String?
|
||||
let list = List(
|
||||
selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
),
|
||||
emptyPlaceholder: "Nothing here"
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
let buffer = renderToBuffer(list, context: context)
|
||||
let content = buffer.lines.joined()
|
||||
|
||||
#expect(content.contains("Nothing here"))
|
||||
}
|
||||
|
||||
@Test("List renders ForEach items")
|
||||
func listRendersForEachItems() {
|
||||
let context = createTestContext()
|
||||
|
||||
struct Item: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
let items = [
|
||||
Item(id: "1", name: "First"),
|
||||
Item(id: "2", name: "Second"),
|
||||
Item(id: "3", name: "Third")
|
||||
]
|
||||
|
||||
var selection: String?
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
ForEach(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
let buffer = renderToBuffer(list, context: context)
|
||||
let content = buffer.lines.joined()
|
||||
|
||||
#expect(content.contains("First"))
|
||||
#expect(content.contains("Second"))
|
||||
#expect(content.contains("Third"))
|
||||
}
|
||||
|
||||
@Test("Selected item has accent indicator")
|
||||
func selectedItemIndicator() {
|
||||
let context = createTestContext()
|
||||
|
||||
struct Item: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
let items = [
|
||||
Item(id: "1", name: "First"),
|
||||
Item(id: "2", name: "Second")
|
||||
]
|
||||
|
||||
var selection: String? = "2"
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
ForEach(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
let buffer = renderToBuffer(list, context: context)
|
||||
let content = buffer.lines.joined()
|
||||
|
||||
// Selected item should show ● indicator
|
||||
#expect(content.contains("●"))
|
||||
}
|
||||
|
||||
@Test("Scroll indicators appear when needed")
|
||||
func scrollIndicatorsAppear() {
|
||||
let context = createTestContext(height: 5)
|
||||
|
||||
struct Item: Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
let items = (0..<20).map { Item(id: $0, name: "Item \($0)") }
|
||||
|
||||
var selection: Int?
|
||||
let list = List(
|
||||
selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
),
|
||||
maxVisibleRows: 3
|
||||
) {
|
||||
ForEach(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
let buffer = renderToBuffer(list, context: context)
|
||||
let content = buffer.lines.joined()
|
||||
|
||||
// Should have "more below" indicator
|
||||
#expect(content.contains("▼") || content.contains("more below"))
|
||||
}
|
||||
|
||||
@Test("Disabled list modifier works")
|
||||
func disabledListModifier() {
|
||||
var selection: String?
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
EmptyView()
|
||||
}.disabled()
|
||||
|
||||
#expect(list.isDisabled == true)
|
||||
}
|
||||
|
||||
@Test("Multi-selection list can be created")
|
||||
func multiSelectionListCreation() {
|
||||
var selection: Set<String> = []
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
Text("Item")
|
||||
}
|
||||
|
||||
#expect(list.selectionMode == .multi)
|
||||
}
|
||||
|
||||
@Test("Single-selection list can be created")
|
||||
func singleSelectionListCreation() {
|
||||
var selection: String?
|
||||
let list = List(selection: Binding(
|
||||
get: { selection },
|
||||
set: { selection = $0 }
|
||||
)) {
|
||||
Text("Item")
|
||||
}
|
||||
|
||||
#expect(list.selectionMode == .single)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
# List & Table Components
|
||||
|
||||
## Preface
|
||||
|
||||
This plan implements the List and Table components in two phases. Phase 1 builds the shared `ItemListHandler` (navigation, selection, scrolling) and the `List` view. Phase 2 adds the `Table` view, reusing the handler and adding column alignment. Both components follow the RadioButtonGroup pattern: a handler class persisted via StateStorage, keyboard navigation within the component, and visual states for focused/selected items.
|
||||
|
||||
## Context / Problem
|
||||
|
||||
TUIKit lacks scrollable list and table components. Users need to display collections with keyboard navigation, selection, and scrolling. The architecture analysis (completed) identified shared patterns between List and Table. Now we implement both components using that shared foundation.
|
||||
|
||||
## Specification / Goal
|
||||
|
||||
Implement:
|
||||
1. `ItemListHandler`: Shared Focusable class for navigation, selection, scrolling
|
||||
2. `List`: Vertical scrollable list with single/multi-selection
|
||||
3. `Table`: Grid with column headers and alignment (reuses ItemListHandler)
|
||||
|
||||
API targets (SwiftUI-inspired):
|
||||
|
||||
```swift
|
||||
// List with single selection
|
||||
List(selection: $selectedID) {
|
||||
ForEach(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// List with multi-selection
|
||||
List(selection: $selectedIDs) {
|
||||
ForEach(items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Table with columns
|
||||
Table(items, selection: $selectedID) {
|
||||
TableColumn("Name", value: \.name)
|
||||
TableColumn("Size", value: \.size)
|
||||
TableColumn("Date", value: \.date)
|
||||
}
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
### ItemListHandler
|
||||
|
||||
Shared handler for both List and Table. Manages:
|
||||
- `focusedIndex`: Currently highlighted item (keyboard cursor)
|
||||
- `scrollOffset`: First visible item index
|
||||
- `viewportHeight`: Number of visible items
|
||||
- `selectionMode`: Single or multi-selection
|
||||
- `selectedIndices`: Set of selected item indices
|
||||
|
||||
Navigation keys (same for both components):
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Up | focusedIndex -= 1 (wrap to end) |
|
||||
| Down | focusedIndex += 1 (wrap to start) |
|
||||
| Home | focusedIndex = 0 |
|
||||
| End | focusedIndex = itemCount - 1 |
|
||||
| PageUp | focusedIndex -= viewportHeight |
|
||||
| PageDown | focusedIndex += viewportHeight |
|
||||
| Enter/Space | Toggle selection at focusedIndex |
|
||||
|
||||
### Visual States
|
||||
|
||||
Four states per item:
|
||||
| State | Rendering |
|
||||
|-------|-----------|
|
||||
| Focused + Selected | Pulsing accent background, bold text |
|
||||
| Focused only | Accent foreground (navigation cursor) |
|
||||
| Selected only | Dimmed accent foreground |
|
||||
| Neither | Default foreground |
|
||||
|
||||
### Scrolling
|
||||
|
||||
Auto-scroll to keep focused item visible:
|
||||
```
|
||||
if focusedIndex < scrollOffset:
|
||||
scrollOffset = focusedIndex
|
||||
if focusedIndex >= scrollOffset + viewportHeight:
|
||||
scrollOffset = focusedIndex - viewportHeight + 1
|
||||
```
|
||||
|
||||
Scroll indicators: Up/down arrows when content extends beyond viewport.
|
||||
|
||||
### List Structure
|
||||
|
||||
```swift
|
||||
public struct List<SelectionValue: Hashable, Content: View>: View {
|
||||
let selection: Binding<SelectionValue?> // Single selection
|
||||
// OR
|
||||
let selection: Binding<Set<SelectionValue>> // Multi selection
|
||||
let content: Content
|
||||
|
||||
public var body: Never { fatalError() }
|
||||
}
|
||||
|
||||
extension List: Renderable {
|
||||
func renderToBuffer(context:) -> FrameBuffer
|
||||
}
|
||||
```
|
||||
|
||||
### Table Structure (Phase 2)
|
||||
|
||||
```swift
|
||||
public struct Table<Value, Content>: View {
|
||||
let data: [Value]
|
||||
let selection: Binding<Value.ID?>
|
||||
let columns: [TableColumn<Value>]
|
||||
}
|
||||
|
||||
public struct TableColumn<Value> {
|
||||
let title: String
|
||||
let alignment: HorizontalAlignment
|
||||
let width: ColumnWidth // .fixed(Int), .flexible, .ratio(Double)
|
||||
let value: (Value) -> String
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: ItemListHandler + List
|
||||
|
||||
1. **ItemListHandler.swift** (Focus/)
|
||||
- Focusable conformance
|
||||
- Navigation logic (Up/Down/Home/End/PageUp/PageDown)
|
||||
- Selection management (single + multi)
|
||||
- Scroll offset calculation
|
||||
- `onFocusLost()` behavior
|
||||
|
||||
2. **List.swift** (Views/)
|
||||
- Public API with selection binding
|
||||
- ForEach-style content via @ViewBuilder
|
||||
- Renderable extension with StateStorage integration
|
||||
- Scroll indicators (arrows when content overflows)
|
||||
- Visual states for items
|
||||
|
||||
3. **ListTests.swift** (Tests/)
|
||||
- Navigation: Up/Down/Home/End/PageUp/PageDown
|
||||
- Selection: single, multi, toggle
|
||||
- Scrolling: auto-scroll on navigation
|
||||
- Edge cases: empty list, single item, disabled
|
||||
|
||||
4. **ListPage.swift** (Example/)
|
||||
- Demo with various list configurations
|
||||
- Single and multi-selection examples
|
||||
|
||||
### Phase 2: Table
|
||||
|
||||
5. **TableColumn.swift** (Views/)
|
||||
- Column definition with title, alignment, width
|
||||
- Value extraction closure
|
||||
|
||||
6. **Table.swift** (Views/)
|
||||
- Reuses ItemListHandler
|
||||
- Column header rendering
|
||||
- ANSI-aware column alignment
|
||||
- Grid layout with separators
|
||||
|
||||
7. **TableTests.swift** (Tests/)
|
||||
- Column alignment
|
||||
- Header rendering
|
||||
- Selection (reuses handler logic)
|
||||
|
||||
8. **TablePage.swift** (Example/)
|
||||
- File browser style table
|
||||
- Multi-column data display
|
||||
|
||||
## Checklist
|
||||
|
||||
### Phase 1: List
|
||||
|
||||
- [ ] ItemListHandler: Navigation logic (Up/Down/Home/End)
|
||||
- [ ] ItemListHandler: PageUp/PageDown navigation
|
||||
- [ ] ItemListHandler: Single selection mode
|
||||
- [ ] ItemListHandler: Multi selection mode
|
||||
- [ ] ItemListHandler: Scroll offset management
|
||||
- [ ] ItemListHandler: onFocusLost behavior
|
||||
- [ ] List: Public API with Binding
|
||||
- [ ] List: Renderable with StateStorage
|
||||
- [ ] List: Visual states (focused/selected)
|
||||
- [ ] List: Scroll indicators
|
||||
- [ ] List: Disabled state
|
||||
- [ ] ListTests: Navigation tests
|
||||
- [ ] ListTests: Selection tests
|
||||
- [ ] ListTests: Scroll tests
|
||||
- [ ] ListPage: Example integration
|
||||
|
||||
### Phase 2: Table
|
||||
|
||||
- [ ] TableColumn: Definition struct
|
||||
- [ ] TableColumn: Width modes (fixed/flexible/ratio)
|
||||
- [ ] Table: Public API
|
||||
- [ ] Table: Header rendering
|
||||
- [ ] Table: ANSI-aware column alignment
|
||||
- [ ] Table: Reuses ItemListHandler
|
||||
- [ ] TableTests: Column tests
|
||||
- [ ] TableTests: Selection tests
|
||||
- [ ] TablePage: Example integration
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Selection API:** SwiftUI uses `List(selection:)` with optional for single, Set for multi. Follow exactly?
|
||||
2. **Row height:** Fixed 1 line per item, or allow multi-line rows?
|
||||
3. **Empty state:** Show placeholder text when list is empty?
|
||||
4. **Table separators:** Vertical lines between columns, or space-only?
|
||||
|
||||
## Files
|
||||
|
||||
New:
|
||||
- `Sources/TUIkit/Focus/ItemListHandler.swift`
|
||||
- `Sources/TUIkit/Views/List.swift`
|
||||
- `Sources/TUIkit/Views/Table.swift`
|
||||
- `Sources/TUIkit/Views/TableColumn.swift`
|
||||
- `Tests/TUIkitTests/ListTests.swift`
|
||||
- `Tests/TUIkitTests/TableTests.swift`
|
||||
- `Sources/TUIkitExample/Pages/ListPage.swift`
|
||||
- `Sources/TUIkitExample/Pages/TablePage.swift`
|
||||
|
||||
Modified:
|
||||
- `Sources/TUIkitExample/ExampleApp.swift` (add menu entries)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- RadioButtonGroup pattern (reference implementation)
|
||||
- ActionHandler pattern (simpler reference)
|
||||
- StateStorage (handler persistence)
|
||||
- FocusManager (registration, key dispatch)
|
||||
- ANSIRenderer (colorize, visual states)
|
||||
Reference in New Issue
Block a user