Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/Sources/Noora/Components/Alert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct Alert {
let content: Content
let logger: Logger?

// swiftlint:disable:next function_body_length
func run() {
let standardPipeline = switch item {
case .error: standardPipelines.error
Expand Down
4 changes: 3 additions & 1 deletion cli/Sources/Noora/Components/MultipleChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum MultipleChoiceLimit {
case limited(count: Int, errorMessage: String)
}

// swiftlint:disable:next type_body_length
struct MultipleChoicePrompt {
// MARK: - Attributes

Expand Down Expand Up @@ -242,6 +243,7 @@ struct MultipleChoicePrompt {
return startIndex ..< endIndex
}

// swiftlint:disable:next function_body_length
private func renderOptions<T: Equatable>(
currentOption: (T, String),
selectedOptions: [(T, String)],
Expand Down Expand Up @@ -322,7 +324,7 @@ struct MultipleChoicePrompt {
let selected = selectedOptions.contains(where: { $0 == option }) ? "◉" : "○"
if option == currentOption {
visibleOptions.append(
"\(titleOffset)\("❯".hex(theme.primary)) \(selected) \(option.1)"
"\(titleOffset)\("❯".hexIfColoredTerminal(theme.primary, terminal)) \(selected) \(option.1)"
)
} else {
visibleOptions.append("\(titleOffset) \(selected) \(option.1)")
Expand Down
3 changes: 2 additions & 1 deletion cli/Sources/Noora/Components/SingleChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct SingleChoicePrompt {

// MARK: - Private

// swiftlint:disable:next function_body_length
private func run<T: Equatable>(options: [(T, String)]) -> T {
if autoselectSingleChoice, options.count == 1 {
renderResult(selectedOption: options[0])
Expand Down Expand Up @@ -235,7 +236,7 @@ struct SingleChoicePrompt {
var visibleOptions = [String]()
for (index, option) in filteredOptions.enumerated() where visibleRange ~= index {
if option == selectedOption {
visibleOptions.append("\(titleOffset) \("❯".hex(theme.primary)) \(option.1)")
visibleOptions.append("\(titleOffset) \("❯".hexIfColoredTerminal(theme.primary, terminal)) \(option.1)")
} else {
visibleOptions.append("\(titleOffset) \(option.1)")
}
Expand Down
3 changes: 3 additions & 0 deletions cli/Sources/Noora/Components/Table/PaginatedTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ struct PaginatedTable {

extension PaginatedTable {
/// Runs the paginated table with keyboard navigation (static mode)
// swiftlint:disable function_body_length
func run() throws {
let effectiveTotalPages = data.pageCount(size: pageSize)
guard effectiveTotalPages > 0 else { return }
Expand Down Expand Up @@ -191,6 +192,8 @@ extension PaginatedTable {
}
}

// swiftlint:enable function_body_length

/// Loads a page asynchronously and renders the result
private func loadPageAndRender(
page: Int,
Expand Down
5 changes: 4 additions & 1 deletion cli/Sources/Noora/Components/Table/SelectableTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct SelectableTable {
let logger: Logger?
let tableRenderer: TableRenderer

// swiftlint:disable function_body_length
/// Runs the interactive table and returns the selected row index
func run() throws -> Int {
guard terminal.isInteractive else {
Expand Down Expand Up @@ -107,6 +108,8 @@ struct SelectableTable {
return finalResult
}

// swiftlint:enable function_body_length

/// Renders the table with selection highlighting
private func renderTableWithSelection(selectedIndex: Int, viewport: TableViewport) {
// Get visible rows
Expand Down Expand Up @@ -196,7 +199,7 @@ struct SelectableTable {

/// Render a selected row with full-width background highlighting and visible borders
private func renderSelectedRow(
_ cells: [TerminalText],
_ cells: TableRow,
layout: TableLayout,
columns: [TableColumn]
) -> String {
Expand Down
6 changes: 3 additions & 3 deletions cli/Sources/Noora/Components/Table/TableColumn.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Defines a column in a table
public struct TableColumn {
public struct TableColumn: Sendable {
/// The title displayed in the header
public let title: TerminalText

Expand All @@ -12,7 +12,7 @@ public struct TableColumn {
public let alignment: Alignment

/// Width configuration options
public enum Width: Equatable {
public enum Width: Equatable, Sendable {
/// Fixed width in characters
case fixed(Int)

Expand All @@ -24,7 +24,7 @@ public struct TableColumn {
}

/// Text alignment options
public enum Alignment: Equatable {
public enum Alignment: Equatable, Sendable {
case left
case center
case right
Expand Down
162 changes: 155 additions & 7 deletions cli/Sources/Noora/Components/Table/TableData.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Semantic styling options for table content
public enum TableCellStyle {
public enum TableCellStyle: Sendable {
case plain(String)
case primary(String)
case secondary(String)
Expand Down Expand Up @@ -34,14 +34,121 @@ public enum TableCellStyle {
}
}

/// A row in a table, containing cells with TerminalText content
public typealias TableRow = [TerminalText]
/// A stable identifier for a table row.
public struct TableRowID: Hashable, Sendable {
private let box: TableRowIDBox

public init(_ id: some Hashable & Sendable) {
box = TableRowIDBox(id)
}

public func unwrap<ID: Hashable & Sendable>(_ type: ID.Type = ID.self) -> ID? {
box.unbox(type)
}

public static func == (lhs: TableRowID, rhs: TableRowID) -> Bool {
lhs.box == rhs.box
}

public func hash(into hasher: inout Hasher) {
box.hash(into: &hasher)
}
}

private struct TableRowIDBox: Hashable, Sendable {
private let hash: @Sendable (inout Hasher) -> Void
private let equals: @Sendable (TableRowIDBox) -> Bool
private let unbox: @Sendable (Any.Type) -> Any?

init<ID: Hashable & Sendable>(_ id: ID) {
hash = { hasher in
id.hash(into: &hasher)
}
unbox = { type in
type == ID.self ? id : nil
}
equals = { other in
guard let otherID = other.unbox(ID.self) as? ID else { return false }
return id == otherID
}
}

func unbox<ID: Hashable & Sendable>(_ type: ID.Type) -> ID? {
unbox(type) as? ID
}

static func == (lhs: TableRowIDBox, rhs: TableRowIDBox) -> Bool {
lhs.equals(rhs)
}

func hash(into hasher: inout Hasher) {
hash(&hasher)
}
}

/// A row in a table, containing cells with TerminalText content and a stable identifier.
public struct TableRow: Identifiable, RandomAccessCollection, Sendable {
public typealias Element = TerminalText
public typealias Index = Int

public let id: TableRowID
public var cells: [TerminalText]

public init(_ cells: [TerminalText], id: TableRowID? = nil) {
self.cells = cells
self.id = id ?? TableRow.defaultID(for: cells)
}

public init(_ styledCells: [TableCellStyle], id: TableRowID? = nil) {
let resolved = styledCells.map { $0.toTerminalText() }
cells = resolved
self.id = id ?? TableRow.defaultID(for: resolved)
}

public init(_ cells: [TerminalText], id: some Hashable & Sendable) {
self.cells = cells
self.id = TableRowID(id)
}

public init(_ styledCells: [TableCellStyle], id: some Hashable & Sendable) {
let resolved = styledCells.map { $0.toTerminalText() }
cells = resolved
self.id = TableRowID(id)
}

public var startIndex: Int { cells.startIndex }
public var endIndex: Int { cells.endIndex }

public subscript(position: Int) -> TerminalText {
cells[position]
}

private static func defaultID(for cells: [TerminalText]) -> TableRowID {
if let firstCell = cells.first {
return TableRowID(firstCell.plain())
}
return TableRowID(cells.map { $0.plain() })
}
}

/// A row in a table using semantic styling
public typealias StyledTableRow = [TableCellStyle]

/// Defines how to build a table cell from a data element.
public struct TerminalRow<Element> {
let render: (Element) -> TerminalText

public init(_ render: @escaping (Element) -> TerminalText) {
self.render = render
}

public init(styled render: @escaping (Element) -> TableCellStyle) {
self.render = { render($0).toTerminalText() }
}
}

/// Represents the data structure for a table
public struct TableData {
public struct TableData: Sendable {
/// Column definitions for the table
public let columns: [TableColumn]

Expand All @@ -57,14 +164,55 @@ public struct TableData {
self.rows = rows
}

/// Creates a new table data structure
/// - Parameters:
/// - columns: Column definitions
/// - rows: Data rows (each row must have same count as columns)
public init(columns: [TableColumn], rows: [[TerminalText]]) {
self.columns = columns
self.rows = rows.map { TableRow($0) }
}

/// Creates a new table data structure with styled content
/// - Parameters:
/// - columns: Column definitions
/// - rows: Data rows using semantic styling
public init(columns: [TableColumn], styledRows: [StyledTableRow]) {
self.columns = columns
rows = styledRows.map { row in
row.map { $0.toTerminalText() }
rows = styledRows.map { TableRow($0) }
}

/// Creates a new table data structure from data with row builders.
/// - Parameters:
/// - data: The data elements used to populate the rows.
/// - columns: Column definitions.
/// - rows: Row builders (one per column).
public init<Data: RandomAccessCollection>(
_ data: Data,
columns: [TableColumn],
rows: [TerminalRow<Data.Element>]
) where Data.Element: Identifiable, Data.Element.ID: Sendable {
self.columns = columns
self.rows = data.map { element in
let cells = rows.map { $0.render(element) }
return TableRow(cells, id: TableRowID(element.id))
}
}

/// Creates a new table data structure from data with row builders.
/// - Parameters:
/// - data: The data elements used to populate the rows.
/// - columns: Column definitions.
/// - rows: Row builders (one per column).
public init<Data: RandomAccessCollection>(
_ data: Data,
columns: [TableColumn],
rows: [TerminalRow<Data.Element>]
) {
self.columns = columns
self.rows = data.map { element in
let cells = rows.map { $0.render(element) }
return TableRow(cells)
}
}

Expand Down Expand Up @@ -94,7 +242,7 @@ public struct TableData {
}

/// Represents a viewport into table rows for scrolling
public struct TableViewport {
public struct TableViewport: Sendable {
/// First visible row index
public var startIndex: Int

Expand Down
6 changes: 3 additions & 3 deletions cli/Sources/Noora/Components/Table/TableRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,15 @@ struct TableRenderer {
}

/// Render a data row
func renderRow(
_ cells: [TerminalText],
func renderRow<Row: RandomAccessCollection>(
_ cells: Row,
layout: TableLayout,
style: TableStyle,
theme: Theme,
terminal: Terminaling,
columns: [TableColumn],
isHeader _: Bool = false
) -> String {
) -> String where Row.Element == TerminalText {
var parts: [String] = []
let chars = style.borderCharacters
let borderColor = theme.muted
Expand Down
16 changes: 16 additions & 0 deletions cli/Sources/Noora/Components/Table/TableSelectionTracking.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// Defines how selection behaves when updating tables reorder rows.
public enum TableSelectionTracking {
/// Keeps the selection anchored to the current index when rows reorder.
case index
/// Tracks selection by a stable key derived from the selected row.
case rowKey(@Sendable (TableRow) -> TableRowID)
/// Automatically tracks rows using the row's identifier (defaults to the first column's text).
case automatic

/// Default tracking strategy for updating selectable tables.
public static var defaultRowKey: TableSelectionTracking {
.automatic
}
}
Loading
Loading