Skip to content
Merged
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
65 changes: 65 additions & 0 deletions Sources/TUIkit/Environment/RuntimeDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 🖥️ TUIKit — Terminal UI Kit for Swift
// RuntimeDiagnostics.swift
//
// License: MIT

import Foundation

/// A deterministic framework diagnostic tied to a render-tree identity.
struct RuntimeDiagnostic: Hashable, Sendable, CustomStringConvertible {
let identity: ViewIdentity
let message: String

var description: String {
"\(message) at \(identity.path)"
}
}

/// Per-runtime diagnostic collector with optional output reporting.
final class RuntimeDiagnostics: @unchecked Sendable {
private struct State: Sendable {
var seen: Set<RuntimeDiagnostic> = []
var current: [RuntimeDiagnostic] = []
}

private let state = Lock(initialState: State())
private let reporter: (@Sendable (RuntimeDiagnostic) -> Void)?

init(reporter: (@Sendable (RuntimeDiagnostic) -> Void)? = nil) {
self.reporter = reporter
}

var messages: [String] {
state.withLock { $0.current.map(\.description) }
}

func beginRenderPass() {
state.withLock { diagnostics in
diagnostics.seen.removeAll(keepingCapacity: true)
diagnostics.current.removeAll(keepingCapacity: true)
}
}

func emit(_ diagnostic: RuntimeDiagnostic) {
let shouldReport = state.withLock { diagnostics -> Bool in
guard diagnostics.seen.insert(diagnostic).inserted else { return false }
diagnostics.current.append(diagnostic)
return true
}

if shouldReport {
reporter?(diagnostic)
}
}

func reset() {
beginRenderPass()
}

static func standardError() -> RuntimeDiagnostics {
RuntimeDiagnostics { diagnostic in
let line = "TUIkit warning: \(diagnostic.description)\n"
FileHandle.standardError.write(Data(line.utf8))
}
}
}
13 changes: 13 additions & 0 deletions Sources/TUIkit/Environment/ServiceEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ private struct PreferenceStorageKey: EnvironmentKey {
static let defaultValue: PreferenceStorage? = nil
}

// MARK: - Runtime Diagnostics

/// EnvironmentKey for diagnostics emitted during view traversal.
private struct RuntimeDiagnosticsKey: EnvironmentKey {
static let defaultValue: RuntimeDiagnostics? = nil
}

// MARK: - Pulse Phase

/// EnvironmentKey for the focus indicator breathing animation phase.
Expand Down Expand Up @@ -119,6 +126,12 @@ extension EnvironmentValues {
set { self[PreferenceStorageKey.self] = newValue }
}

/// Diagnostics emitted by the runtime that owns this render tree.
var runtimeDiagnostics: RuntimeDiagnostics? {
get { self[RuntimeDiagnosticsKey.self] }
set { self[RuntimeDiagnosticsKey.self] = newValue }
}

/// The current breathing animation phase (0-1) for the focus indicator.
var pulsePhase: Double {
get { self[PulsePhaseKey.self] }
Expand Down
10 changes: 10 additions & 0 deletions Sources/TUIkit/Environment/TUIContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ final class TUIContext {
/// Preference value collection during rendering.
let preferences: PreferenceStorage

/// Diagnostics emitted while traversing this runtime's view tree.
let runtimeDiagnostics: RuntimeDiagnostics

/// Persistent `@State` value storage indexed by `ViewIdentity`.
let stateStorage: StateStorage

Expand Down Expand Up @@ -422,6 +425,7 @@ final class TUIContext {
/// - lifecycle: The lifecycle manager to use.
/// - keyEventDispatcher: The key event dispatcher to use.
/// - preferences: The preference storage to use.
/// - runtimeDiagnostics: The diagnostic collector to use.
/// - stateStorage: The state storage to use.
/// - observationRegistry: The Observation registry to use.
/// - renderCache: The render cache to use.
Expand All @@ -438,6 +442,7 @@ final class TUIContext {
lifecycle: LifecycleManager = LifecycleManager(),
keyEventDispatcher: KeyEventDispatcher = KeyEventDispatcher(),
preferences: PreferenceStorage = PreferenceStorage(),
runtimeDiagnostics: RuntimeDiagnostics = RuntimeDiagnostics(),
stateStorage: StateStorage = StateStorage(),
observationRegistry: ObservationRegistry = ObservationRegistry(),
renderCache: RenderCache = RenderCache(),
Expand Down Expand Up @@ -469,6 +474,7 @@ final class TUIContext {
self.lifecycle = lifecycle
self.keyEventDispatcher = keyEventDispatcher
self.preferences = preferences
self.runtimeDiagnostics = runtimeDiagnostics
self.stateStorage = stateStorage
self.observationRegistry = observationRegistry
self.renderCache = renderCache
Expand Down Expand Up @@ -498,6 +504,7 @@ extension TUIContext {
/// Creates a runtime backed by the user's persistent configuration.
static func production() -> TUIContext {
TUIContext(
runtimeDiagnostics: .standardError(),
storageBackend: StorageDefaults.runtimeBackend,
localizationService: LocalizationService()
)
Expand All @@ -507,6 +514,7 @@ extension TUIContext {
func beginRenderPass() {
keyEventDispatcher.clearHandlers()
preferences.beginRenderPass()
runtimeDiagnostics.beginRenderPass()
focusManager.beginRenderPass()
statusBar.clearSectionItems()
appHeader.beginRenderPass()
Expand Down Expand Up @@ -539,6 +547,7 @@ extension TUIContext {
environment.renderCache = renderCache
environment.renderInvalidationSink = appState
environment.preferenceStorage = preferences
environment.runtimeDiagnostics = runtimeDiagnostics
environment.localizationService = localizationService
environment.notificationService = notificationService
environment.storageBackend = storageBackend
Expand Down Expand Up @@ -583,6 +592,7 @@ extension TUIContext {
lifecycle.reset()
keyEventDispatcher.clearHandlers()
preferences.reset()
runtimeDiagnostics.reset()
stateStorage.reset()
observationRegistry.reset()
renderCache.reset()
Expand Down
38 changes: 36 additions & 2 deletions Sources/TUIkit/Focus/ItemListHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,22 @@ final class ItemListHandler<SelectionValue: Hashable>: Focusable {
/// Maps item indices to their IDs for selection management.
///
/// Entries are `nil` for non-selectable rows (e.g. section headers/footers in List).
var itemIDs: [SelectionValue?] = []
var itemIDs: [SelectionValue?] = [] {
didSet {
preserveFocusedItem(from: oldValue)
}
}

/// The set of indices that can be selected and focused.
///
/// Headers and footers have non-selectable indices (not in this set).
/// Only content rows have indices in `selectableIndices`.
/// When empty, all items are considered selectable (backward compatibility).
var selectableIndices: Set<Int> = []
var selectableIndices: Set<Int> = [] {
didSet {
moveFocusToSelectableItemIfNeeded()
}
}

/// Creates an item list handler.
///
Expand Down Expand Up @@ -204,6 +212,32 @@ extension ItemListHandler {
// MARK: - Navigation Helpers

extension ItemListHandler {
/// Keeps the keyboard cursor attached to the same item when rows move.
private func preserveFocusedItem(from previousIDs: [SelectionValue?]) {
guard !itemIDs.isEmpty else {
focusedIndex = 0
scrollOffset = 0
return
}

if previousIDs.indices.contains(focusedIndex),
let focusedID = previousIDs[focusedIndex],
let reorderedIndex = itemIDs.firstIndex(of: focusedID) {
focusedIndex = reorderedIndex
} else {
focusedIndex = min(focusedIndex, itemIDs.count - 1)
}
}

/// Moves a removed or non-selectable cursor to the nearest valid row.
private func moveFocusToSelectableItemIfNeeded() {
guard !selectableIndices.isEmpty,
!selectableIndices.contains(focusedIndex) else { return }

let orderedIndices = selectableIndices.sorted()
focusedIndex = orderedIndices.first(where: { $0 >= focusedIndex }) ?? orderedIndices.last ?? 0
}

/// Moves focus by the given delta, optionally wrapping around.
///
/// - Parameters:
Expand Down
35 changes: 19 additions & 16 deletions Sources/TUIkit/Modifiers/LifecycleModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ struct OnAppearModifier<Content: View>: View {
extension OnAppearModifier: Renderable {
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let scopedContext = context.withIdentityScope("lifecycle.appear")
_ = context.environment.lifecycle!.recordAppear(
identity: scopedContext.identity,
action: action
)
if !context.isMeasuring {
_ = context.environment.lifecycle!.recordAppear(
identity: scopedContext.identity,
action: action
)
}

return TUIkit.renderToBuffer(content, context: scopedContext)
}
Expand All @@ -49,10 +51,11 @@ struct OnDisappearModifier<Content: View>: View {
extension OnDisappearModifier: Renderable {
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let scopedContext = context.withIdentityScope("lifecycle.disappear")
let lifecycle = context.environment.lifecycle!

lifecycle.registerDisappear(identity: scopedContext.identity, action: action)
_ = lifecycle.recordAppear(identity: scopedContext.identity, action: {})
if !context.isMeasuring {
let lifecycle = context.environment.lifecycle!
lifecycle.registerDisappear(identity: scopedContext.identity, action: action)
_ = lifecycle.recordAppear(identity: scopedContext.identity, action: {})
}

return TUIkit.renderToBuffer(content, context: scopedContext)
}
Expand Down Expand Up @@ -81,14 +84,14 @@ struct TaskModifier<Content: View>: View {
extension TaskModifier: Renderable {
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let scopedContext = context.withIdentityScope("lifecycle.task")
let lifecycle = context.environment.lifecycle!

lifecycle.updateTask(
identity: scopedContext.identity,
id: MountedTaskID.value,
priority: priority,
operation: task
)
if !context.isMeasuring {
context.environment.lifecycle!.updateTask(
identity: scopedContext.identity,
id: MountedTaskID.value,
priority: priority,
operation: task
)
}

return TUIkit.renderToBuffer(content, context: scopedContext)
}
Expand Down
84 changes: 84 additions & 0 deletions Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 🖥️ TUIKit — Terminal UI Kit for Swift
// KeyedCollectionSnapshot.swift
//
// License: MIT

/// A materialized keyed collection used by dynamic views and row containers.
///
/// Construction is linear. Every entry receives an occurrence-disambiguated
/// identity while public selection APIs continue to use the original ID.
struct KeyedCollectionSnapshot<Element, ID: Hashable> {
struct Entry {
let element: Element
let id: ID
let identityKey: KeyedCollectionIdentity<ID>
}

struct Duplicate {
let id: ID
let offsets: [Int]
}

let entries: [Entry]
let duplicates: [Duplicate]

init<Data: Collection>(
_ data: Data,
id idKeyPath: KeyPath<Element, ID>
) where Data.Element == Element {
var entries: [Entry] = []
entries.reserveCapacity(data.underestimatedCount)

var offsetsByID: [ID: [Int]] = [:]
var duplicateOrder: [ID] = []

for (offset, element) in data.enumerated() {
let id = element[keyPath: idKeyPath]
let occurrence = offsetsByID[id, default: []].count
offsetsByID[id, default: []].append(offset)
if occurrence == 1 {
duplicateOrder.append(id)
}

entries.append(
Entry(
element: element,
id: id,
identityKey: KeyedCollectionIdentity(id: id, occurrence: occurrence)
)
)
}

self.entries = entries
self.duplicates = duplicateOrder.compactMap { id in
guard let offsets = offsetsByID[id] else { return nil }
return Duplicate(id: id, offsets: offsets)
}
}
}

/// Internal identity key that keeps duplicate occurrences deterministic.
struct KeyedCollectionIdentity<ID: Hashable>: Hashable, CustomDebugStringConvertible {
let id: ID
let occurrence: Int

var debugDescription: String {
"\(String(reflecting: id))#\(occurrence)"
}
}

extension KeyedCollectionSnapshot {
func reportDuplicates(container: String, context: RenderContext) {
guard let diagnostics = context.environment.runtimeDiagnostics else { return }

for duplicate in duplicates {
let offsets = duplicate.offsets.map(String.init).joined(separator: ", ")
diagnostics.emit(
RuntimeDiagnostic(
identity: context.identity,
message: "\(container) contains duplicate ID \(String(reflecting: duplicate.id)) at offsets \(offsets)"
)
)
}
}
}
Loading
Loading