From 84fdb7ace2938065d07d9d873746ffcc3c1320ca Mon Sep 17 00:00:00 2001 From: phranck Date: Sat, 7 Feb 2026 19:08:19 +0100 Subject: [PATCH] Feat: Add Table component with column support - TableColumn: Column definition with title, alignment, width modes - Key path and closure-based value extraction - Width modes: .fixed(Int), .flexible, .ratio(Double) - Alignment: .leading, .center, .trailing - Chainable modifiers: .alignment(), .width() - Table: SwiftUI-compatible table with column headers - Single selection: Table(data, selection: Binding) - Multi-selection: Table(data, selection: Binding>) - Reuses ItemListHandler for navigation/selection - ANSI-aware column alignment - Header row with column titles - Space-only separators (no vertical lines) - Scroll indicators when content overflows - Empty state placeholder - .disabled() modifier support - TableColumnBuilder: Result builder for column DSL - TablePage: Example with file browser style demo - 21 new tests for Table and TableColumn Completes Phase 2 of List & Table plan. --- Sources/TUIkit/Views/Table.swift | 455 ++++++++++++++++++ Sources/TUIkit/Views/TableColumn.swift | 155 ++++++ Sources/TUIkitExample/ContentView.swift | 8 + Sources/TUIkitExample/Pages/ListPage.swift | 26 +- .../TUIkitExample/Pages/MainMenuPage.swift | 1 + Sources/TUIkitExample/Pages/TablePage.swift | 119 +++++ Tests/TUIkitTests/TableTests.swift | 345 +++++++++++++ .../2026-02-07-list-table-components.md | 88 ++-- 8 files changed, 1146 insertions(+), 51 deletions(-) create mode 100644 Sources/TUIkit/Views/Table.swift create mode 100644 Sources/TUIkit/Views/TableColumn.swift create mode 100644 Sources/TUIkitExample/Pages/TablePage.swift create mode 100644 Tests/TUIkitTests/TableTests.swift rename plans/{open => done}/2026-02-07-list-table-components.md (77%) diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift new file mode 100644 index 00000000..f1c50b7d --- /dev/null +++ b/Sources/TUIkit/Views/Table.swift @@ -0,0 +1,455 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// Table.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +// MARK: - Table (Single Selection) + +/// A scrollable table with columns, keyboard navigation, and selection. +/// +/// `Table` displays tabular data with column headers and supports: +/// - Keyboard navigation (Up/Down/Home/End/PageUp/PageDown) +/// - Single selection via optional binding +/// - Multi-selection via Set binding +/// - Configurable column widths (fixed, flexible, ratio) +/// - Column alignment (leading, center, trailing) +/// - ANSI-aware column layout +/// - Scrolling with automatic viewport management +/// +/// ## Usage +/// +/// ```swift +/// struct FileInfo: Identifiable { +/// let id: String +/// let name: String +/// let size: String +/// let modified: String +/// } +/// +/// @State var selectedID: String? +/// +/// Table(files, selection: $selectedID) { +/// TableColumn("Name", value: \.name) +/// TableColumn("Size", value: \.size) +/// .width(.fixed(10)) +/// .alignment(.trailing) +/// TableColumn("Modified", value: \.modified) +/// .width(.ratio(0.3)) +/// } +/// ``` +/// +/// ## Column Spacing +/// +/// Columns are separated by spaces (no vertical lines) for a clean look. +public struct Table: View where Value.ID: Hashable { + /// The data items to display. + let data: [Value] + + /// The column definitions. + let columns: [TableColumn] + + /// Binding for single selection (optional ID). + let singleSelection: Binding? + + /// Binding for multi-selection (Set of IDs). + let multiSelection: Binding>? + + /// The selection mode derived from which binding is set. + var selectionMode: SelectionMode { + multiSelection != nil ? .multi : .single + } + + /// The unique focus identifier for this table. + let focusID: String? + + /// Whether the table is disabled. + var isDisabled: Bool + + /// The maximum number of visible rows (nil = use available height). + let maxVisibleRows: Int? + + /// The placeholder text shown when the table is empty. + let emptyPlaceholder: String + + /// The spacing between columns in characters. + let columnSpacing: Int + + public var body: Never { + fatalError("Table renders via Renderable") + } +} + +// MARK: - Single Selection Initializer + +extension Table { + /// Creates a table with single selection. + /// + /// - Parameters: + /// - data: The data items to display. + /// - 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). + /// - columnSpacing: Spacing between columns (default: 2). + /// - emptyPlaceholder: Placeholder text when empty (default: "No items"). + /// - columns: A builder that defines the table columns. + public init( + _ data: [Value], + selection: Binding, + focusID: String? = nil, + maxVisibleRows: Int? = nil, + columnSpacing: Int = 2, + emptyPlaceholder: String = "No items", + @TableColumnBuilder columns: () -> [TableColumn] + ) { + self.data = data + self.columns = columns() + self.singleSelection = selection + self.multiSelection = nil + self.focusID = focusID + self.isDisabled = false + self.maxVisibleRows = maxVisibleRows + self.columnSpacing = columnSpacing + self.emptyPlaceholder = emptyPlaceholder + } +} + +// MARK: - Multi Selection Initializer + +extension Table { + /// Creates a table with multi-selection. + /// + /// - Parameters: + /// - data: The data items to display. + /// - 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). + /// - columnSpacing: Spacing between columns (default: 2). + /// - emptyPlaceholder: Placeholder text when empty (default: "No items"). + /// - columns: A builder that defines the table columns. + public init( + _ data: [Value], + selection: Binding>, + focusID: String? = nil, + maxVisibleRows: Int? = nil, + columnSpacing: Int = 2, + emptyPlaceholder: String = "No items", + @TableColumnBuilder columns: () -> [TableColumn] + ) { + self.data = data + self.columns = columns() + self.singleSelection = nil + self.multiSelection = selection + self.focusID = focusID + self.isDisabled = false + self.maxVisibleRows = maxVisibleRows + self.columnSpacing = columnSpacing + self.emptyPlaceholder = emptyPlaceholder + } +} + +// MARK: - Convenience Modifiers + +extension Table { + /// Creates a disabled version of this table. + /// + /// - Parameter disabled: Whether the table is disabled. + /// - Returns: A new table with the disabled state. + public func disabled(_ disabled: Bool = true) -> Table { + var copy = self + copy.isDisabled = disabled + return copy + } +} + +// MARK: - Rendering + +extension Table: Renderable { + func renderToBuffer(context: RenderContext) -> FrameBuffer { + let focusManager = context.environment.focusManager + let palette = context.environment.palette + let stateStorage = context.tuiContext.stateStorage + + // Handle empty state + guard !data.isEmpty else { + return renderEmptyState(palette: palette) + } + + // Calculate column widths + let availableWidth = context.availableWidth + let columnWidths = calculateColumnWidths( + availableWidth: availableWidth, + spacing: columnSpacing + ) + + // Calculate viewport height (reserve 1 line for header, 2 for scroll indicators) + let availableHeight = context.availableHeight + let viewportHeight = maxVisibleRows ?? max(1, availableHeight - 3) + + // Get or create persistent focusID + let focusIDKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 1) + let focusIDBox: StateBox = stateStorage.storage( + for: focusIDKey, + default: focusID ?? "table-\(context.identity.path)" + ) + let persistedFocusID = focusIDBox.value + + // Get or create persistent handler + let handlerKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) + let handlerBox: StateBox = stateStorage.storage( + for: handlerKey, + default: ItemListHandler( + focusID: persistedFocusID, + itemCount: data.count, + viewportHeight: viewportHeight, + selectionMode: selectionMode, + canBeFocused: !isDisabled + ) + ) + let handler = handlerBox.value + + // Update handler with current values + handler.itemCount = data.count + handler.viewportHeight = viewportHeight + handler.canBeFocused = !isDisabled + handler.itemIDs = data.map { AnyHashable($0.id) } + + // Set up selection bindings + if let binding = singleSelection { + handler.singleSelection = Binding( + get: { binding.wrappedValue.map { AnyHashable($0) } }, + set: { newValue in + binding.wrappedValue = newValue?.base as? Value.ID + } + ) + } + if let binding = multiSelection { + handler.multiSelection = Binding>( + get: { Set(binding.wrappedValue.map { AnyHashable($0) }) }, + set: { newValue in + binding.wrappedValue = Set(newValue.compactMap { $0.base as? Value.ID }) + } + ) + } + + // Ensure focused item is visible + handler.ensureFocusedItemVisible() + + // Register with focus manager + focusManager.register(handler, inSection: context.activeFocusSectionID) + stateStorage.markActive(context.identity) + + // Check if this table has focus + let tableHasFocus = focusManager.isFocused(id: persistedFocusID) + + // Render output + var lines: [String] = [] + + // Header row + lines.append(renderHeader(columnWidths: columnWidths, palette: palette)) + + // Top scroll indicator + if handler.hasContentAbove { + lines.append(renderScrollIndicator(direction: .up, width: availableWidth, palette: palette)) + } + + // Data rows + let visibleRange = handler.visibleRange + for rowIndex in visibleRange { + let item = data[rowIndex] + let isFocused = handler.isFocused(at: rowIndex) && tableHasFocus + let isSelected = handler.isSelected(at: rowIndex) + + lines.append(renderRow( + item: item, + columnWidths: columnWidths, + isFocused: isFocused, + isSelected: isSelected, + context: context, + palette: palette + )) + } + + // Bottom scroll indicator + if handler.hasContentBelow { + lines.append(renderScrollIndicator(direction: .down, width: availableWidth, palette: palette)) + } + + return FrameBuffer(lines: lines) + } +} + +// MARK: - Column Width Calculation + +private extension Table { + /// Calculates the width for each column. + func calculateColumnWidths(availableWidth: Int, spacing: Int) -> [Int] { + guard !columns.isEmpty else { return [] } + + // Calculate total spacing between columns + let totalSpacing = spacing * (columns.count - 1) + + // Reserve space for row indicator (2 chars: indicator + space) + let indicatorWidth = 2 + let contentWidth = max(0, availableWidth - totalSpacing - indicatorWidth) + + // First pass: allocate fixed widths and ratios + var widths = [Int](repeating: 0, count: columns.count) + var usedWidth = 0 + var flexibleIndices: [Int] = [] + + for (index, column) in columns.enumerated() { + switch column.width { + case .fixed(let fixedWidth): + widths[index] = fixedWidth + usedWidth += fixedWidth + case .ratio(let ratio): + let ratioWidth = Int(Double(contentWidth) * ratio) + widths[index] = ratioWidth + usedWidth += ratioWidth + case .flexible: + flexibleIndices.append(index) + } + } + + // Second pass: distribute remaining space to flexible columns + if !flexibleIndices.isEmpty { + let remainingWidth = max(0, contentWidth - usedWidth) + let perColumn = remainingWidth / flexibleIndices.count + let remainder = remainingWidth % flexibleIndices.count + + for (offset, index) in flexibleIndices.enumerated() { + widths[index] = perColumn + (offset < remainder ? 1 : 0) + } + } + + // Ensure minimum width of 1 for each column + return widths.map { max(1, $0) } + } +} + +// MARK: - Header Rendering + +private extension Table { + /// Renders the header row. + func renderHeader(columnWidths: [Int], palette: any Palette) -> String { + let spacing = String(repeating: " ", count: columnSpacing) + + // Build header cells + let cells = zip(columns, columnWidths).map { column, width -> String in + let aligned = alignText(column.title, width: width, alignment: column.alignment) + return ANSIRenderer.colorize(aligned, foreground: palette.foregroundSecondary, bold: true) + } + + // Join with spacing and add indicator placeholder + return " " + cells.joined(separator: spacing) + } +} + +// MARK: - Row Rendering + +private extension Table { + /// Renders a single data row. + func renderRow( + item: Value, + columnWidths: [Int], + isFocused: Bool, + isSelected: Bool, + context: RenderContext, + palette: any Palette + ) -> String { + let spacing = String(repeating: " ", count: columnSpacing) + + // Determine row indicator and colors + let indicator: String + let foregroundColor: Color + + if isFocused && isSelected { + let dimAccent = palette.accent.opacity(0.35) + foregroundColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase) + indicator = "●" + } else if isFocused { + foregroundColor = palette.accent + indicator = "›" + } else if isSelected { + foregroundColor = palette.accent.opacity(0.6) + indicator = "●" + } else { + foregroundColor = palette.foreground + indicator = " " + } + + // Style the indicator + let styledIndicator = ANSIRenderer.colorize( + indicator, + foreground: foregroundColor, + bold: isFocused + ) + + // Build cells + let cells = zip(columns, columnWidths).map { column, width -> String in + let value = column.value(for: item) + let aligned = alignText(value, width: width, alignment: column.alignment) + return ANSIRenderer.colorize(aligned, foreground: foregroundColor) + } + + return styledIndicator + " " + cells.joined(separator: spacing) + } +} + +// MARK: - Text Alignment + +private extension Table { + /// Aligns text within the specified width. + func alignText(_ text: String, width: Int, alignment: HorizontalAlignment) -> String { + let visibleLength = text.strippedLength + let padding = max(0, width - visibleLength) + + switch alignment { + case .leading: + return text + String(repeating: " ", count: padding) + case .center: + let leftPad = padding / 2 + let rightPad = padding - leftPad + return String(repeating: " ", count: leftPad) + text + String(repeating: " ", count: rightPad) + case .trailing: + return String(repeating: " ", count: padding) + text + } + } +} + +// MARK: - Scroll Indicators + +private extension Table { + 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 Table { + func renderEmptyState(palette: any Palette) -> FrameBuffer { + let styledText = ANSIRenderer.colorize( + emptyPlaceholder, + foreground: palette.foregroundTertiary + ) + return FrameBuffer(lines: [styledText]) + } +} diff --git a/Sources/TUIkit/Views/TableColumn.swift b/Sources/TUIkit/Views/TableColumn.swift new file mode 100644 index 00000000..070e74f7 --- /dev/null +++ b/Sources/TUIkit/Views/TableColumn.swift @@ -0,0 +1,155 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TableColumn.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +// MARK: - Column Width + +/// Defines the width behavior for a table column. +public enum ColumnWidth: Sendable, Equatable { + /// Fixed width in characters. + case fixed(Int) + + /// Flexible width that expands to fill available space. + /// Multiple flexible columns share space equally. + case flexible + + /// Proportional width as a ratio of total available space. + /// For example, `.ratio(0.5)` takes 50% of available width. + case ratio(Double) +} + +// MARK: - Table Column + +/// Defines a column in a Table view. +/// +/// `TableColumn` specifies how to display a property of the data items, +/// including the column header, alignment, width, and value extraction. +/// +/// ## Usage +/// +/// ```swift +/// Table(files, selection: $selectedID) { +/// TableColumn("Name", value: \.name) +/// TableColumn("Size", value: \.formattedSize) +/// .width(.fixed(10)) +/// .alignment(.trailing) +/// TableColumn("Modified", value: \.modifiedDate) +/// .width(.ratio(0.3)) +/// } +/// ``` +/// +/// ## Width Modes +/// +/// | Mode | Behavior | +/// |------|----------| +/// | `.fixed(n)` | Exactly n characters wide | +/// | `.flexible` | Expands to fill remaining space (shared equally) | +/// | `.ratio(r)` | Takes r proportion of available width (0.0-1.0) | +public struct TableColumn: Sendable { + /// The column header title. + public let title: String + + /// The horizontal alignment for column content. + public var alignment: HorizontalAlignment + + /// The width mode for this column. + public var width: ColumnWidth + + /// Extracts the display value from a data item. + let valueExtractor: @Sendable (Value) -> String + + /// Creates a table column with a key path to a String property. + /// + /// - Parameters: + /// - title: The column header title. + /// - value: A key path to the String property to display. + public init(_ title: String, value: KeyPath & Sendable) where Value: Sendable { + self.title = title + self.alignment = .leading + self.width = .flexible + self.valueExtractor = { item in item[keyPath: value] } + } + + /// Creates a table column with a custom value extractor. + /// + /// - Parameters: + /// - title: The column header title. + /// - value: A closure that extracts the display string from a data item. + public init(_ title: String, value: @escaping @Sendable (Value) -> String) { + self.title = title + self.alignment = .leading + self.width = .flexible + self.valueExtractor = value + } +} + +// MARK: - Modifiers + +extension TableColumn { + /// Sets the alignment for this column. + /// + /// - Parameter alignment: The horizontal alignment. + /// - Returns: A modified column with the specified alignment. + public func alignment(_ alignment: HorizontalAlignment) -> TableColumn { + var copy = self + copy.alignment = alignment + return copy + } + + /// Sets the width mode for this column. + /// + /// - Parameter width: The column width mode. + /// - Returns: A modified column with the specified width. + public func width(_ width: ColumnWidth) -> TableColumn { + var copy = self + copy.width = width + return copy + } +} + +// MARK: - Column Content Extraction + +extension TableColumn { + /// Extracts the display value from an item. + /// + /// - Parameter item: The data item. + /// - Returns: The string to display in this column. + func value(for item: Value) -> String { + valueExtractor(item) + } +} + +// MARK: - Table Column Builder + +/// A result builder for composing table columns. +@resultBuilder +public struct TableColumnBuilder { + /// Builds an array of columns from a single column. + public static func buildBlock(_ columns: TableColumn...) -> [TableColumn] { + columns + } + + /// Builds an array of columns from an array. + public static func buildArray(_ components: [[TableColumn]]) -> [TableColumn] { + components.flatMap { $0 } + } + + /// Builds an optional column. + public static func buildOptional(_ component: [TableColumn]?) -> [TableColumn] { + component ?? [] + } + + /// Builds the first branch of an if-else. + public static func buildEither(first component: [TableColumn]) -> [TableColumn] { + component + } + + /// Builds the second branch of an if-else. + public static func buildEither(second component: [TableColumn]) -> [TableColumn] { + component + } +} diff --git a/Sources/TUIkitExample/ContentView.swift b/Sources/TUIkitExample/ContentView.swift index 9cf0049f..c77b40cf 100644 --- a/Sources/TUIkitExample/ContentView.swift +++ b/Sources/TUIkitExample/ContentView.swift @@ -21,6 +21,7 @@ enum DemoPage: Int, CaseIterable { case radioButtons case spinners case lists + case tables } // MARK: - Content View (Page Router) @@ -62,6 +63,10 @@ struct ContentView: View { // Quick jump to Lists currentPage = .lists return true + case .character("-"): + // Quick jump to Tables + currentPage = .tables + return true default: return false // Let other handlers process } @@ -107,6 +112,9 @@ struct ContentView: View { case .lists: ListPage() .statusBarItems(subPageItems(pageSetter: pageSetter)) + case .tables: + TablePage() + .statusBarItems(subPageItems(pageSetter: pageSetter)) } } diff --git a/Sources/TUIkitExample/Pages/ListPage.swift b/Sources/TUIkitExample/Pages/ListPage.swift index 13b7cc1e..26b35956 100644 --- a/Sources/TUIkitExample/Pages/ListPage.swift +++ b/Sources/TUIkitExample/Pages/ListPage.swift @@ -15,19 +15,19 @@ private struct FileItem: Identifiable { 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: "📁"), + static let sampleFiles: [Self] = [ + Self(id: "1", name: "README.md", size: "4.2 KB", icon: "📄"), + Self(id: "2", name: "Package.swift", size: "1.8 KB", icon: "📦"), + Self(id: "3", name: "Sources", size: "128 KB", icon: "📁"), + Self(id: "4", name: "Tests", size: "64 KB", icon: "📁"), + Self(id: "5", name: ".gitignore", size: "0.5 KB", icon: "📄"), + Self(id: "6", name: "LICENSE", size: "1.1 KB", icon: "📄"), + Self(id: "7", name: "docs", size: "256 KB", icon: "📁"), + Self(id: "8", name: "plans", size: "32 KB", icon: "📁"), + Self(id: "9", name: ".swiftlint.yml", size: "1.2 KB", icon: "⚙️"), + Self(id: "10", name: ".github", size: "8 KB", icon: "📁"), + Self(id: "11", name: "Makefile", size: "0.8 KB", icon: "📄"), + Self(id: "12", name: ".claude", size: "16 KB", icon: "📁"), ] } diff --git a/Sources/TUIkitExample/Pages/MainMenuPage.swift b/Sources/TUIkitExample/Pages/MainMenuPage.swift index 1fdc120e..3091d2b9 100644 --- a/Sources/TUIkitExample/Pages/MainMenuPage.swift +++ b/Sources/TUIkitExample/Pages/MainMenuPage.swift @@ -63,6 +63,7 @@ struct MainMenuPage: View { MenuItem(label: "Radio Buttons", shortcut: "8"), MenuItem(label: "Spinners", shortcut: "9"), MenuItem(label: "Lists", shortcut: "0"), + MenuItem(label: "Tables", shortcut: "-"), ], selection: $menuSelection, onSelect: { index in diff --git a/Sources/TUIkitExample/Pages/TablePage.swift b/Sources/TUIkitExample/Pages/TablePage.swift new file mode 100644 index 00000000..afff8e2a --- /dev/null +++ b/Sources/TUIkitExample/Pages/TablePage.swift @@ -0,0 +1,119 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TablePage.swift +// +// Created by LAYERED.work +// License: MIT + +import TUIkit + +// MARK: - Demo Data + +/// A file entry for the table demo. +private struct FileEntry: Identifiable, Sendable { + let id: String + let name: String + let size: String + let modified: String + let type: String + + static let sampleFiles: [Self] = [ + Self(id: "1", name: "README.md", size: "4.2 KB", modified: "2026-02-07", type: "Markdown"), + Self(id: "2", name: "Package.swift", size: "1.8 KB", modified: "2026-02-06", type: "Swift"), + Self(id: "3", name: "Sources/", size: "128 KB", modified: "2026-02-07", type: "Directory"), + Self(id: "4", name: "Tests/", size: "64 KB", modified: "2026-02-05", type: "Directory"), + Self(id: "5", name: ".gitignore", size: "0.5 KB", modified: "2026-01-15", type: "Config"), + Self(id: "6", name: "LICENSE", size: "1.1 KB", modified: "2026-01-01", type: "Text"), + Self(id: "7", name: "docs/", size: "256 KB", modified: "2026-02-04", type: "Directory"), + Self(id: "8", name: "plans/", size: "32 KB", modified: "2026-02-07", type: "Directory"), + Self(id: "9", name: ".swiftlint.yml", size: "1.2 KB", modified: "2026-02-02", type: "YAML"), + Self(id: "10", name: ".github/", size: "8 KB", modified: "2026-01-20", type: "Directory"), + Self(id: "11", name: "Makefile", size: "0.8 KB", modified: "2026-02-01", type: "Makefile"), + Self(id: "12", name: ".claude/", size: "16 KB", modified: "2026-02-07", type: "Directory"), + ] +} + +// MARK: - Table Page + +/// Table component demo page. +/// +/// Shows interactive table features including: +/// - Column definitions with key paths +/// - Column alignment (leading, center, trailing) +/// - Column width modes (fixed, flexible, ratio) +/// - Single and multi-selection +/// - Keyboard navigation +/// - Scroll indicators +struct TablePage: View { + @State var singleSelection: String? + @State var multiSelection: Set = [] + + var body: some View { + VStack(spacing: 1) { + + DemoSection("File Browser (Single Selection)") { + Table( + FileEntry.sampleFiles, + selection: $singleSelection, + maxVisibleRows: 6 + ) { + TableColumn("Name", value: \FileEntry.name) + TableColumn("Size", value: \FileEntry.size) + .width(.fixed(10)) + .alignment(.trailing) + TableColumn("Modified", value: \FileEntry.modified) + .width(.fixed(12)) + TableColumn("Type", value: \FileEntry.type) + .width(.fixed(10)) + } + } + + DemoSection("Multi-Selection Table") { + Table( + FileEntry.sampleFiles, + selection: $multiSelection, + maxVisibleRows: 4 + ) { + TableColumn("Name", value: \FileEntry.name) + TableColumn("Type", value: \FileEntry.type) + .width(.fixed(12)) + } + } + + 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("Navigation") { + VStack { + Text("Use [Up/Down] to navigate rows").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 tables").dim() + } + } + + Spacer() + } + .appHeader { + HStack { + Text("Table Demo").bold().foregroundColor(.palette.accent) + Spacer() + Text("TUIkit v\(tuiKitVersion)").foregroundColor(.palette.foregroundTertiary) + } + } + } +} diff --git a/Tests/TUIkitTests/TableTests.swift b/Tests/TUIkitTests/TableTests.swift new file mode 100644 index 00000000..5e1dcba7 --- /dev/null +++ b/Tests/TUIkitTests/TableTests.swift @@ -0,0 +1,345 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TableTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing + +@testable import TUIkit + +// MARK: - Test Data + +private struct FileInfo: Identifiable, Sendable { + let id: String + let name: String + let size: String + let modified: String +} + +private let testFiles: [FileInfo] = [ + FileInfo(id: "1", name: "README.md", size: "2.4 KB", modified: "2026-02-07"), + FileInfo(id: "2", name: "Package.swift", size: "1.1 KB", modified: "2026-02-06"), + FileInfo(id: "3", name: "main.swift", size: "512 B", modified: "2026-02-05"), +] + +// 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: - TableColumn Tests + +@Suite("TableColumn Tests") +@MainActor +struct TableColumnTests { + @Test("TableColumn extracts value via key path") + func keyPathExtraction() { + let column = TableColumn("Name", value: \FileInfo.name) + let file = FileInfo(id: "1", name: "test.txt", size: "1 KB", modified: "2026-01-01") + + #expect(column.value(for: file) == "test.txt") + } + + @Test("TableColumn extracts value via closure") + func closureExtraction() { + let column = TableColumn("Size") { file in + "Size: \(file.size)" + } + let file = FileInfo(id: "1", name: "test.txt", size: "1 KB", modified: "2026-01-01") + + #expect(column.value(for: file) == "Size: 1 KB") + } + + @Test("TableColumn defaults to leading alignment") + func defaultAlignment() { + let column = TableColumn("Name", value: \FileInfo.name) + #expect(column.alignment == .leading) + } + + @Test("TableColumn defaults to flexible width") + func defaultWidth() { + let column = TableColumn("Name", value: \FileInfo.name) + #expect(column.width == .flexible) + } + + @Test("TableColumn alignment modifier creates new instance") + func alignmentModifier() { + let column = TableColumn("Size", value: \FileInfo.size) + let aligned = column.alignment(.trailing) + + #expect(column.alignment == .leading) + #expect(aligned.alignment == .trailing) + } + + @Test("TableColumn width modifier creates new instance") + func widthModifier() { + let column = TableColumn("Size", value: \FileInfo.size) + let fixed = column.width(.fixed(10)) + + #expect(column.width == .flexible) + #expect(fixed.width == .fixed(10)) + } + + @Test("TableColumn modifiers can be chained") + func chainedModifiers() { + let column = TableColumn("Size", value: \FileInfo.size) + .alignment(.trailing) + .width(.fixed(10)) + + #expect(column.alignment == .trailing) + #expect(column.width == .fixed(10)) + } +} + +// MARK: - Table Rendering Tests + +@Suite("Table Rendering Tests") +@MainActor +struct TableRenderingTests { + @Test("Table renders header row") + func headerRendering() { + let context = createTestContext(width: 60, height: 10) + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + TableColumn("Size", value: \FileInfo.size) + } + + let buffer = renderToBuffer(table, context: context) + + // Header should be on first line + let headerLine = buffer.lines[0].stripped + #expect(headerLine.contains("Name")) + #expect(headerLine.contains("Size")) + } + + @Test("Table renders data rows") + func dataRowRendering() { + let context = createTestContext(width: 60, height: 10) + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + let buffer = renderToBuffer(table, context: context) + let content = buffer.lines.map { $0.stripped }.joined(separator: "\n") + + #expect(content.contains("README.md")) + #expect(content.contains("Package.swift")) + #expect(content.contains("main.swift")) + } + + @Test("Table shows empty placeholder when no data") + func emptyState() { + let context = createTestContext(width: 40, height: 10) + let emptyData: [FileInfo] = [] + var selection: String? + + let table = Table(emptyData, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + let buffer = renderToBuffer(table, context: context) + let content = buffer.lines.map { $0.stripped }.joined() + + #expect(content.contains("No items")) + } + + @Test("Table shows custom empty placeholder") + func customEmptyPlaceholder() { + let context = createTestContext(width: 40, height: 10) + let emptyData: [FileInfo] = [] + var selection: String? + + let table = Table( + emptyData, + selection: Binding(get: { selection }, set: { selection = $0 }), + emptyPlaceholder: "No files found" + ) { + TableColumn("Name", value: \FileInfo.name) + } + + let buffer = renderToBuffer(table, context: context) + let content = buffer.lines.map { $0.stripped }.joined() + + #expect(content.contains("No files found")) + } + + @Test("Table renders column values correctly") + func columnValues() { + let context = createTestContext(width: 80, height: 10) + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + TableColumn("Size", value: \FileInfo.size) + TableColumn("Modified", value: \FileInfo.modified) + } + + let buffer = renderToBuffer(table, context: context) + let content = buffer.lines.map { $0.stripped }.joined(separator: "\n") + + // Check first file's values appear + #expect(content.contains("README.md")) + #expect(content.contains("2.4 KB")) + #expect(content.contains("2026-02-07")) + } + + @Test("Table respects fixed column width") + func fixedColumnWidth() { + let context = createTestContext(width: 60, height: 10) + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + TableColumn("Size", value: \FileInfo.size) + .width(.fixed(10)) + } + + let buffer = renderToBuffer(table, context: context) + + // The table should render without error with fixed widths + #expect(buffer.lines.count > 1) + } + + @Test("Table applies trailing alignment") + func trailingAlignment() { + let context = createTestContext(width: 40, height: 10) + var selection: String? + let singleFile = [FileInfo(id: "1", name: "test", size: "1 KB", modified: "2026-01-01")] + + let table = Table(singleFile, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Size", value: \FileInfo.size) + .width(.fixed(10)) + .alignment(.trailing) + } + + let buffer = renderToBuffer(table, context: context) + + // With trailing alignment on a 10-char column, "1 KB" (4 chars) should have 6 spaces before it + let dataLine = buffer.lines[1].stripped + #expect(dataLine.contains("1 KB")) + } +} + +// MARK: - Table Selection Tests + +@Suite("Table Selection Tests") +@MainActor +struct TableSelectionTests { + @Test("Table single selection binding updates") + func singleSelectionBinding() { + let context = createTestContext() + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + // Render to set up handler + _ = renderToBuffer(table, context: context) + + // Selection starts as nil + #expect(selection == nil) + } + + @Test("Table multi-selection binding updates") + func multiSelectionBinding() { + let context = createTestContext() + var selection: Set = [] + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + // Render to set up handler + _ = renderToBuffer(table, context: context) + + // Selection starts empty + #expect(selection.isEmpty) + } + + @Test("Single-selection table has correct mode") + func singleSelectionMode() { + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + #expect(table.selectionMode == .single) + } + + @Test("Multi-selection table has correct mode") + func multiSelectionMode() { + var selection: Set = [] + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + } + + #expect(table.selectionMode == .multi) + } +} + +// MARK: - TableColumnBuilder Tests + +@Suite("TableColumnBuilder Tests") +@MainActor +struct TableColumnBuilderTests { + @Test("Builder creates array from multiple columns") + func multipleColumns() { + @TableColumnBuilder + var columns: [TableColumn] { + TableColumn("Name", value: \FileInfo.name) + TableColumn("Size", value: \FileInfo.size) + TableColumn("Modified", value: \FileInfo.modified) + } + + #expect(columns.count == 3) + #expect(columns[0].title == "Name") + #expect(columns[1].title == "Size") + #expect(columns[2].title == "Modified") + } +} + +// MARK: - Table Disabled Tests + +@Suite("Table Disabled Tests") +@MainActor +struct TableDisabledTests { + @Test("Disabled modifier sets isDisabled") + func disabledModifier() { + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + }.disabled() + + #expect(table.isDisabled == true) + } + + @Test("Disabled modifier with false keeps enabled") + func disabledFalse() { + var selection: String? + + let table = Table(testFiles, selection: Binding(get: { selection }, set: { selection = $0 })) { + TableColumn("Name", value: \FileInfo.name) + }.disabled(false) + + #expect(table.isDisabled == false) + } +} diff --git a/plans/open/2026-02-07-list-table-components.md b/plans/done/2026-02-07-list-table-components.md similarity index 77% rename from plans/open/2026-02-07-list-table-components.md rename to plans/done/2026-02-07-list-table-components.md index f45faecb..f438e568 100644 --- a/plans/open/2026-02-07-list-table-components.md +++ b/plans/done/2026-02-07-list-table-components.md @@ -4,6 +4,49 @@ 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. +## Completed + +**Date:** 2026-02-07 + +Both phases completed successfully. List and Table components are fully implemented with: +- Shared `ItemListHandler` for navigation, selection, and scrolling +- SwiftUI-compatible APIs with single and multi-selection bindings +- Column alignment and width modes for Table +- 53 new tests (32 List/Handler + 21 Table) +- Example pages demonstrating all features + +## Checklist + +### Phase 1: List + +- [x] ItemListHandler: Navigation logic (Up/Down/Home/End) +- [x] ItemListHandler: PageUp/PageDown navigation +- [x] ItemListHandler: Single selection mode +- [x] ItemListHandler: Multi selection mode +- [x] ItemListHandler: Scroll offset management +- [x] ItemListHandler: onFocusLost behavior +- [x] List: Public API with Binding +- [x] List: Renderable with StateStorage +- [x] List: Visual states (focused/selected) +- [x] List: Scroll indicators +- [x] List: Disabled state +- [x] ListTests: Navigation tests +- [x] ListTests: Selection tests +- [x] ListTests: Scroll tests +- [x] ListPage: Example integration + +### Phase 2: Table + +- [x] TableColumn: Definition struct +- [x] TableColumn: Width modes (fixed/flexible/ratio) +- [x] Table: Public API +- [x] Table: Header rendering +- [x] Table: ANSI-aware column alignment +- [x] Table: Reuses ItemListHandler +- [x] TableTests: Column tests +- [x] TableTests: Selection tests +- [x] TablePage: Example integration + ## 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. @@ -167,44 +210,12 @@ public struct TableColumn { - File browser style table - Multi-column data display -## Checklist +## Open Questions (Resolved) -### 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? +1. **Selection API:** SwiftUI uses `List(selection:)` with optional for single, Set for multi. Follow exactly? **Yes, followed exactly.** +2. **Row height:** Fixed 1 line per item, or allow multi-line rows? **Multi-line rows supported in List.** +3. **Empty state:** Show placeholder text when list is empty? **Yes, with customizable placeholder.** +4. **Table separators:** Vertical lines between columns, or space-only? **Space-only for clean look.** ## Files @@ -219,7 +230,8 @@ New: - `Sources/TUIkitExample/Pages/TablePage.swift` Modified: -- `Sources/TUIkitExample/ExampleApp.swift` (add menu entries) +- `Sources/TUIkitExample/ContentView.swift` (add menu entries and shortcuts) +- `Sources/TUIkitExample/Pages/MainMenuPage.swift` (add menu items) ## Dependencies