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
15 changes: 12 additions & 3 deletions cli/Sources/Noora/Components/Table/TableData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,29 +48,38 @@ public struct TableData {
/// Data rows for the table
public let rows: [TableRow]

/// Optional row identifiers aligned to `rows` (used for selection tracking in updating tables)
public let rowIDs: [AnyHashable]?

/// 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: [TableRow]) {
/// - rowIDs: Optional identifiers for each row (must align with rows)
public init(columns: [TableColumn], rows: [TableRow], rowIDs: [AnyHashable]? = nil) {
self.columns = columns
self.rows = rows
self.rowIDs = rowIDs
}

/// 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]) {
/// - rowIDs: Optional identifiers for each row (must align with rows)
public init(columns: [TableColumn], styledRows: [StyledTableRow], rowIDs: [AnyHashable]? = nil) {
self.columns = columns
rows = styledRows.map { row in
row.map { $0.toTerminalText() }
}
self.rowIDs = rowIDs
}

/// Validates that all rows have the correct number of cells
public var isValid: Bool {
rows.allSatisfy { $0.count == columns.count }
let rowCountsValid = rows.allSatisfy { $0.count == columns.count }
let rowIDsValid = rowIDs.map { $0.count == rows.count } ?? true
return rowCountsValid && rowIDsValid
}

/// Returns a subset of rows for pagination
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) -> AnyHashable)
/// Automatically tracks rows using row IDs when provided, falling back to the first column's text.
case automatic

/// Default tracking strategy for updating selectable tables.
public static var defaultRowKey: TableSelectionTracking {
.automatic
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import Foundation
import Logging

/// An interactive table that keeps updating as new data arrives.
struct UpdatingSelectableTable<Updates: AsyncSequence> where Updates.Element == TableData {
/// @unchecked Sendable: rendering and state access are serialized by internal queues.
struct UpdatingSelectableTable<Updates: AsyncSequence>: @unchecked Sendable where Updates.Element == TableData {
let initialData: TableData
let updates: Updates
let style: TableStyle
let pageSize: Int
let selectionTracking: TableSelectionTracking
let renderer: Rendering
let standardPipelines: StandardPipelines
let terminal: Terminaling
Expand Down Expand Up @@ -36,7 +38,8 @@ struct UpdatingSelectableTable<Updates: AsyncSequence> where Updates.Element ==
startIndex: 0,
size: min(pageSize, initialData.rows.count),
totalRows: initialData.rows.count
)
),
selectionTracking: selectionTracking
)

let group = DispatchGroup()
Expand All @@ -53,7 +56,12 @@ struct UpdatingSelectableTable<Updates: AsyncSequence> where Updates.Element ==

group.enter()
Task {
listenForInput(state: state)
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
listenForInput(state: state)
continuation.resume()
}
}
group.leave()
}

Expand Down Expand Up @@ -316,24 +324,34 @@ struct UpdatingSelectableTable<Updates: AsyncSequence> where Updates.Element ==
}
}

private final class LiveSelectableState {
/// @unchecked Sendable: all state access is serialized by the internal queue.
private final class LiveSelectableState: @unchecked Sendable {
struct Snapshot {
let data: TableData
let selectedIndex: Int
let viewport: TableViewport
}

private let queue = DispatchQueue(label: "live-selectable-table")
private let selectionTracking: TableSelectionTracking
private var data: TableData
private var selectedIndex: Int
private var viewport: TableViewport
private var selectionKey: AnyHashable?
private var stopped = false
private var selection: Int?

init(data: TableData, selectedIndex: Int, viewport: TableViewport) {
init(
data: TableData,
selectedIndex: Int,
viewport: TableViewport,
selectionTracking: TableSelectionTracking
) {
self.selectionTracking = selectionTracking
self.data = data
self.selectedIndex = selectedIndex
self.viewport = viewport
selectionKey = selectionKey(for: data, selectedIndex: selectedIndex)
}

func snapshot() -> Snapshot {
Expand All @@ -345,6 +363,10 @@ private final class LiveSelectableState {
func updateData(_ newData: TableData, pageSize: Int) -> Snapshot? {
queue.sync {
guard newData.isValid, !newData.rows.isEmpty else { return nil }
if let matchedIndex = selectionIndex(in: newData) {
selectedIndex = matchedIndex
}

data = newData

if selectedIndex >= data.rows.count {
Expand All @@ -360,6 +382,7 @@ private final class LiveSelectableState {
var v = viewport
v.scrollToShow(selectedIndex)
viewport = v
selectionKey = selectionKey(for: data, selectedIndex: selectedIndex)

return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport)
}
Expand All @@ -373,6 +396,7 @@ private final class LiveSelectableState {
var v = viewport
v.scrollToShow(selectedIndex)
viewport = v
selectionKey = selectionKey(for: data, selectedIndex: selectedIndex)
return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport)
}
}
Expand All @@ -384,6 +408,7 @@ private final class LiveSelectableState {
var v = viewport
v.scrollToShow(selectedIndex)
viewport = v
selectionKey = selectionKey(for: data, selectedIndex: selectedIndex)
return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport)
}
}
Expand All @@ -395,6 +420,7 @@ private final class LiveSelectableState {
var v = viewport
v.scrollToShow(selectedIndex)
viewport = v
selectionKey = selectionKey(for: data, selectedIndex: selectedIndex)
return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport)
}
}
Expand Down Expand Up @@ -425,4 +451,41 @@ private final class LiveSelectableState {
return selection
}
}

private func selectionKey(for data: TableData, selectedIndex: Int) -> AnyHashable? {
keyForRow(in: data, index: selectedIndex)
}

private func selectionIndex(in data: TableData) -> Int? {
guard let selectionKey else { return nil }
switch selectionTracking {
case .index:
return nil
case .rowKey, .automatic:
for index in data.rows.indices {
if keyForRow(in: data, index: index) == selectionKey {
return index
}
}
return nil
}
}

private func keyForRow(in data: TableData, index: Int) -> AnyHashable? {
guard data.rows.indices.contains(index) else { return nil }
switch selectionTracking {
case .index:
return nil
case let .rowKey(selector):
return selector(data.rows[index])
case .automatic:
if let rowIDs = data.rowIDs, rowIDs.indices.contains(index) {
return rowIDs[index]
}
if let firstCell = data.rows[index].first {
return AnyHashable(firstCell.plain())
}
return AnyHashable(data.rows[index].map { $0.plain() })
}
}
}
10 changes: 10 additions & 0 deletions cli/Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,12 +387,14 @@ public protocol Noorable: Sendable {
/// - data: Initial table data to render.
/// - updates: An async sequence emitting new table data to render.
/// - pageSize: Number of rows visible at once.
/// - selectionTracking: Controls how selection behaves when rows reorder. Use `.automatic` to track row IDs.
/// - renderer: A rendering interface that holds the UI state.
/// - Returns: Selected row index.
func selectableTable<Updates: AsyncSequence>(
_ data: TableData,
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking,
renderer: Rendering
) async throws -> Int where Updates.Element == TableData

Expand Down Expand Up @@ -876,13 +878,15 @@ public final class Noora: Noorable {
_ data: TableData,
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking = .automatic,
renderer: Rendering = Renderer()
) async throws -> Int where Updates.Element == TableData {
let component = UpdatingSelectableTable(
initialData: data,
updates: updates,
style: theme.tableStyle,
pageSize: pageSize,
selectionTracking: selectionTracking,
renderer: renderer,
standardPipelines: standardPipelines,
terminal: terminal,
Expand All @@ -900,13 +904,15 @@ public final class Noora: Noorable {
rows: [[String]],
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking = .automatic,
renderer: Rendering = Renderer()
) async throws -> Int where Updates.Element == TableData {
let tableData = createTableData(headers: headers, rows: rows)
return try await selectableTable(
tableData,
updates: updates,
pageSize: pageSize,
selectionTracking: selectionTracking,
renderer: renderer
)
}
Expand All @@ -916,13 +922,15 @@ public final class Noora: Noorable {
rows: [StyledTableRow],
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking = .automatic,
renderer: Rendering = Renderer()
) async throws -> Int where Updates.Element == TableData {
let tableData = createStyledTableData(headers: headers, rows: rows)
return try await selectableTable(
tableData,
updates: updates,
pageSize: pageSize,
selectionTracking: selectionTracking,
renderer: renderer
)
}
Expand Down Expand Up @@ -1341,12 +1349,14 @@ extension Noorable {
_ data: TableData,
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking = .automatic,
renderer: Rendering = Renderer()
) async throws -> Int where Updates.Element == TableData {
try await selectableTable(
data,
updates: updates,
pageSize: pageSize,
selectionTracking: selectionTracking,
renderer: renderer
)
}
Expand Down
2 changes: 2 additions & 0 deletions cli/Sources/Noora/NooraMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,14 @@
_ data: TableData,
updates: Updates,
pageSize: Int,
selectionTracking: TableSelectionTracking,
renderer: Rendering
) async throws -> Int where Updates.Element == TableData {
try await noora.selectableTable(
data,
updates: updates,
pageSize: pageSize,
selectionTracking: selectionTracking,
renderer: renderer
)
}
Expand Down
Loading
Loading