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:
phranck
2026-02-07 18:57:51 +01:00
parent e5a2cb2185
commit 9b8548bdbc
8 changed files with 1774 additions and 0 deletions
+305
View File
@@ -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
}
}
+456
View File
@@ -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])
}
}
+8
View File
@@ -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))
}
}
+124
View File
@@ -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)
}
}
+201
View File
@@ -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)