diff --git a/Sources/TUIkit/Environment/RuntimeDiagnostics.swift b/Sources/TUIkit/Environment/RuntimeDiagnostics.swift new file mode 100644 index 000000000..3af297769 --- /dev/null +++ b/Sources/TUIkit/Environment/RuntimeDiagnostics.swift @@ -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 = [] + 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)) + } + } +} diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift index 555709c29..06c299fdd 100644 --- a/Sources/TUIkit/Environment/ServiceEnvironment.swift +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -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. @@ -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] } diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index b55e5967d..f524d1668 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -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 @@ -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. @@ -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(), @@ -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 @@ -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() ) @@ -507,6 +514,7 @@ extension TUIContext { func beginRenderPass() { keyEventDispatcher.clearHandlers() preferences.beginRenderPass() + runtimeDiagnostics.beginRenderPass() focusManager.beginRenderPass() statusBar.clearSectionItems() appHeader.beginRenderPass() @@ -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 @@ -583,6 +592,7 @@ extension TUIContext { lifecycle.reset() keyEventDispatcher.clearHandlers() preferences.reset() + runtimeDiagnostics.reset() stateStorage.reset() observationRegistry.reset() renderCache.reset() diff --git a/Sources/TUIkit/Focus/ItemListHandler.swift b/Sources/TUIkit/Focus/ItemListHandler.swift index 431766334..63ec85455 100644 --- a/Sources/TUIkit/Focus/ItemListHandler.swift +++ b/Sources/TUIkit/Focus/ItemListHandler.swift @@ -84,14 +84,22 @@ final class ItemListHandler: 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 = [] + var selectableIndices: Set = [] { + didSet { + moveFocusToSelectableItemIfNeeded() + } + } /// Creates an item list handler. /// @@ -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: diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index c0d047668..cae769529 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -22,10 +22,12 @@ struct OnAppearModifier: 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) } @@ -49,10 +51,11 @@ struct OnDisappearModifier: 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) } @@ -81,14 +84,14 @@ struct TaskModifier: 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) } diff --git a/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift b/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift new file mode 100644 index 000000000..a0410448a --- /dev/null +++ b/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift @@ -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 { + struct Entry { + let element: Element + let id: ID + let identityKey: KeyedCollectionIdentity + } + + struct Duplicate { + let id: ID + let offsets: [Int] + } + + let entries: [Entry] + let duplicates: [Duplicate] + + init( + _ data: Data, + id idKeyPath: KeyPath + ) 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: 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)" + ) + ) + } + } +} diff --git a/Sources/TUIkit/Views/ForEach.swift b/Sources/TUIkit/Views/ForEach.swift index 02a8242be..84a6c03c1 100644 --- a/Sources/TUIkit/Views/ForEach.swift +++ b/Sources/TUIkit/Views/ForEach.swift @@ -10,16 +10,8 @@ /// element. The collection elements must be `Identifiable` or an /// explicit ID key path must be provided. /// -/// ## Rendering -/// -/// `ForEach` has **no standalone rendering capability**. It declares -/// `body: Never` but does *not* conform to `Renderable`. On its own, -/// it would produce an empty ``FrameBuffer``. -/// -/// In practice, `ForEach` is always used inside a `@ViewBuilder` block -/// (e.g. within `VStack` or `HStack`). The builder's `buildArray` -/// method flattens it into a ``ViewArray``, which *is* `Renderable`. -/// This is the same pattern SwiftUI uses. +/// Each generated child is associated with the element's explicit ID. The +/// identity remains stable when elements are inserted, removed, or reordered. /// /// # Example with Identifiable /// @@ -75,13 +67,28 @@ public struct ForEach self.content = content } - /// Never called — `ForEach` is flattened into a ``ViewArray`` by - /// `@ViewBuilder.buildArray` before rendering occurs. - /// - /// - Important: Accessing this property directly will crash at runtime. - /// Always use `ForEach` inside a `@ViewBuilder` closure (e.g., `VStack`, `HStack`). - public var body: Never { - fatalError("ForEach has no standalone rendering; use inside a @ViewBuilder block") + public var body: some View { + core + } +} + +// MARK: - Dynamic Children + +extension ForEach: ChildInfoProvider, ChildViewProvider { + public func childInfos(context: RenderContext) -> [ChildInfo] { + core.childInfos(context: context) + } + + public func childViews(context: RenderContext) -> [ChildView] { + core.childViews(context: context) + } +} + +extension ForEach { + func keyedSnapshot(context: RenderContext) -> KeyedCollectionSnapshot { + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + return snapshot } } @@ -120,3 +127,51 @@ extension ForEach where Data == Range, ID == Int { self.content = content } } + +// MARK: - Private Core + +private extension ForEach { + var core: _ForEachCore { + _ForEachCore(data: data, idKeyPath: idKeyPath, content: content) + } +} + +private struct _ForEachCore: View, Renderable, + ChildInfoProvider, ChildViewProvider { + let data: Data + let idKeyPath: KeyPath + let content: (Data.Element) -> Content + + var body: Never { + fatalError("_ForEachCore renders its dynamic children directly") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + + return snapshot.entries.map { entry in + let view = content(entry.element) + return makeChildInfo( + for: view, + context: context.withKeyedChildIdentity( + type: Content.self, + key: entry.identityKey + ) + ) + } + } + + func childViews(context: RenderContext) -> [ChildView] { + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + + return snapshot.entries.map { entry in + ChildView(content(entry.element), key: entry.identityKey) + } + } +} diff --git a/Sources/TUIkit/Views/ListRowExtractor.swift b/Sources/TUIkit/Views/ListRowExtractor.swift index 2f11b8a3a..2d2696686 100644 --- a/Sources/TUIkit/Views/ListRowExtractor.swift +++ b/Sources/TUIkit/Views/ListRowExtractor.swift @@ -37,17 +37,18 @@ protocol ListRowExtractor { extension ForEach: ListRowExtractor { func extractListRows(context: RenderContext) -> [ListRow] { - data.compactMap { element -> ListRow? in - let elementID = element[keyPath: idKeyPath] - let view = content(element) + keyedSnapshot(context: context).entries.compactMap { entry -> ListRow? in + let view = content(entry.element) // Extract badge if the view is wrapped in a BadgeModifier let badge = extractBadgeValue(from: view) - // Render the view - let buffer = TUIkit.renderToBuffer(view, context: context) + // Render each row below its explicit collection identity so state, + // lifecycle, and other runtime records follow the row across reorder. + let rowContext = context.withKeyedChildIdentity(type: Content.self, key: entry.identityKey) + let buffer = TUIkit.renderToBuffer(view, context: rowContext) - guard let rowID = elementID as? RowID else { return nil } + guard let rowID = entry.id as? RowID else { return nil } return ListRow(id: rowID, buffer: buffer, badge: badge) } } diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift index 4c344ab62..e95123f03 100644 --- a/Sources/TUIkit/Views/Table.swift +++ b/Sources/TUIkit/Views/Table.swift @@ -191,6 +191,8 @@ private struct _TableCore: View, Renderable wher func renderToBuffer(context: RenderContext) -> FrameBuffer { let palette = context.environment.palette let stateStorage = context.environment.stateStorage! + let snapshot = KeyedCollectionSnapshot(data, id: \.id) + snapshot.reportDuplicates(container: "Table", context: context) // Calculate available width inside container (subtract border + padding) let innerWidth = max(0, context.availableWidth - 4) @@ -207,7 +209,7 @@ private struct _TableCore: View, Renderable wher // Handle empty state let contentLines: [String] - if data.isEmpty { + if snapshot.entries.isEmpty { contentLines = [emptyPlaceholder] } else { // Calculate viewport height @@ -228,7 +230,7 @@ private struct _TableCore: View, Renderable wher for: handlerKey, default: ItemListHandler( focusID: persistedFocusID, - itemCount: data.count, + itemCount: snapshot.entries.count, viewportHeight: viewportHeight, selectionMode: selectionMode, canBeFocused: !isDisabled @@ -237,10 +239,10 @@ private struct _TableCore: View, Renderable wher let handler = handlerBox.value // Update handler with current values - handler.itemCount = data.count + handler.itemCount = snapshot.entries.count handler.viewportHeight = viewportHeight handler.canBeFocused = !isDisabled - handler.itemIDs = data.map { $0.id } + handler.itemIDs = snapshot.entries.map(\.id) // Assign selection bindings directly (type-safe, no AnyHashable conversion) handler.singleSelection = singleSelection @@ -263,7 +265,7 @@ private struct _TableCore: View, Renderable wher // Data rows let visibleRange = handler.visibleRange for rowIndex in visibleRange { - let item = data[rowIndex] + let item = snapshot.entries[rowIndex].element let isFocused = handler.isFocused(at: rowIndex) && tableHasFocus let isSelected = handler.isSelected(at: rowIndex) diff --git a/Sources/TUIkitView/Core/PrimitiveViews.swift b/Sources/TUIkitView/Core/PrimitiveViews.swift index a6f5a1514..24ded4c69 100644 --- a/Sources/TUIkitView/Core/PrimitiveViews.swift +++ b/Sources/TUIkitView/Core/PrimitiveViews.swift @@ -55,7 +55,7 @@ public enum ConditionalView: View { /// This type is used internally by `ViewBuilder` for for-in loops. /// /// ```swift -/// ForEach(items) { item in +/// for item in items { /// Text(item.name) /// } /// ``` @@ -143,19 +143,71 @@ extension ConditionalView: Renderable { } } +// MARK: - ConditionalView Child Traversal + +extension ConditionalView: ChildInfoProvider, ChildViewProvider { + public func childInfos(context: RenderContext) -> [ChildInfo] { + switch self { + case .trueContent(let content): + invalidateInactiveBranch("false", context: context) + return resolveChildInfos(from: content, context: context.withBranchIdentity("true")) + case .falseContent(let content): + invalidateInactiveBranch("true", context: context) + return resolveChildInfos(from: content, context: context.withBranchIdentity("false")) + } + } + + public func childViews(context: RenderContext) -> [ChildView] { + switch self { + case .trueContent(let content): + invalidateInactiveBranch("false", context: context) + return scopedChildViews(from: content, branch: "true", context: context) + case .falseContent(let content): + invalidateInactiveBranch("true", context: context) + return scopedChildViews(from: content, branch: "false", context: context) + } + } + + private func invalidateInactiveBranch(_ branch: String, context: RenderContext) { + context.environment.stateStorage?.invalidateDescendants(of: context.identity.branch(branch)) + } + + private func scopedChildViews( + from content: Content, + branch: String, + context: RenderContext + ) -> [ChildView] { + let branchContext = context.withBranchIdentity(branch) + return resolveChildViews(from: content, context: branchContext).map { + $0.scoped(to: branchContext.identity) + } + } +} + // MARK: - ViewArray Rendering -extension ViewArray: Renderable, ChildInfoProvider { +extension ViewArray: Renderable, ChildInfoProvider, ChildViewProvider { public func renderToBuffer(context: RenderContext) -> FrameBuffer { FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) } public func childInfos(context: RenderContext) -> [ChildInfo] { - elements.enumerated().map { index, element in - makeChildInfo( - for: element, - context: context.withChildIdentity(type: type(of: element), index: index) - ) + elements.enumerated().flatMap { index, element in + let childContext = context.withChildIdentity(type: Element.self, index: index) + return resolveChildInfos(from: element, context: childContext) + } + } + + public func childViews(context: RenderContext) -> [ChildView] { + elements.enumerated().flatMap { index, element in + guard let provider = element as? ChildViewProvider else { + return [ChildView(element, childIndex: index)] + } + + let childContext = context.withChildIdentity(type: Element.self, index: index) + return provider.childViews(context: childContext).map { + $0.scoped(to: childContext.identity) + } } } } diff --git a/Sources/TUIkitView/Core/TupleViews.swift b/Sources/TUIkitView/Core/TupleViews.swift index c2c25d0bf..f42a942b6 100644 --- a/Sources/TUIkitView/Core/TupleViews.swift +++ b/Sources/TUIkitView/Core/TupleViews.swift @@ -52,12 +52,13 @@ extension TupleView: Renderable, ChildInfoProvider { public func childInfos(context: RenderContext) -> [ChildInfo] { var infos: [ChildInfo] = [] - repeat infos.append( - makeChildInfo( - for: each children, - context: context.withChildIdentity(type: type(of: each children), index: infos.count) - ) - ) + var childIndex = 0 + func append(_ child: Child) { + let childContext = context.withChildIdentity(type: Child.self, index: childIndex) + childIndex += 1 + infos.append(contentsOf: resolveChildInfos(from: child, context: childContext)) + } + repeat append(each children) return infos } } @@ -67,9 +68,21 @@ extension TupleView: Renderable, ChildInfoProvider { extension TupleView: ChildViewProvider { public func childViews(context: RenderContext) -> [ChildView] { var views: [ChildView] = [] - repeat views.append( - ChildView(each children, childIndex: views.count) - ) + var childIndex = 0 + func append(_ child: Child) { + let index = childIndex + childIndex += 1 + + if let provider = child as? ChildViewProvider { + let childContext = context.withChildIdentity(type: Child.self, index: index) + views.append(contentsOf: provider.childViews(context: childContext).map { + $0.scoped(to: childContext.identity) + }) + } else { + views.append(ChildView(child, childIndex: index)) + } + } + repeat append(each children) return views } } diff --git a/Sources/TUIkitView/Rendering/ChildInfo.swift b/Sources/TUIkitView/Rendering/ChildInfo.swift index 425c0822a..9be7b1745 100644 --- a/Sources/TUIkitView/Rendering/ChildInfo.swift +++ b/Sources/TUIkitView/Rendering/ChildInfo.swift @@ -23,6 +23,18 @@ public struct ChildView { /// The minimum length of this spacer (only relevant if isSpacer is true). public let spacerMinLength: Int? + private init( + isSpacer: Bool, + spacerMinLength: Int?, + measure: @escaping (ProposedSize, RenderContext) -> ViewSize, + render: @escaping (Int, Int, RenderContext) -> FrameBuffer + ) { + self.isSpacer = isSpacer + self.spacerMinLength = spacerMinLength + self._measure = measure + self._render = render + } + public init(_ view: V) { if let spacer = view as? SpacerProtocol { self.isSpacer = true @@ -68,6 +80,47 @@ public struct ChildView { } } + /// Creates a child wrapper whose identity is derived from an explicit key. + /// + /// Keyed children keep the same runtime identity when their collection is + /// inserted into or reordered. + package init(_ view: V, key: ID) { + if let spacer = view as? SpacerProtocol { + self.isSpacer = true + self.spacerMinLength = spacer.spacerMinLength + } else { + self.isSpacer = false + self.spacerMinLength = nil + } + + self._measure = { proposal, context in + let childContext = context.withKeyedChildIdentity(type: V.self, key: key) + return measureChild(view, proposal: proposal, context: childContext) + } + self._render = { width, height, context in + let childContext = context.withKeyedChildIdentity(type: V.self, key: key) + return renderChild(view, width: width, height: height, context: childContext) + } + } + + /// Re-bases this type-erased child below a previously resolved identity. + package func scoped(to identity: ViewIdentity) -> Self { + Self( + isSpacer: isSpacer, + spacerMinLength: spacerMinLength, + measure: { proposal, context in + var scopedContext = context + scopedContext.identity = identity + return self.measure(proposal: proposal, context: scopedContext) + }, + render: { width, height, context in + var scopedContext = context + scopedContext.identity = identity + return self.render(width: width, height: height, context: scopedContext) + } + ) + } + /// Measures this child view without rendering. public func measure(proposal: ProposedSize, context: RenderContext) -> ViewSize { _measure(proposal, context) diff --git a/Tests/TUIkitTests/ComponentViewTests.swift b/Tests/TUIkitTests/ComponentViewTests.swift index 38a538581..691e08bb3 100644 --- a/Tests/TUIkitTests/ComponentViewTests.swift +++ b/Tests/TUIkitTests/ComponentViewTests.swift @@ -198,11 +198,36 @@ struct ForEachTests { #expect(generatedTexts == ["Alpha", "Beta"]) } - // NOTE: ForEach inside VStack/HStack cannot be tested via renderToBuffer - // directly. ForEach is flattened into ViewArray by @ViewBuilder.buildArray - // at compile time — not at render time. Direct construction in tests - // bypasses the builder, so ForEach remains unflattened and produces - // an empty buffer. This is expected behavior, matching SwiftUI's pattern. + @Test("ForEach expands between static HStack children") + func forEachExpandsInsideTupleContent() { + let items = [TestItem(id: "a", name: "Alpha"), TestItem(id: "b", name: "Beta")] + let stack = HStack(spacing: 1) { + Text("Before") + ForEach(items) { item in + Text(item.name) + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["Before Alpha Beta After"]) + } + + @Test("ForEach expands through native builder arrays") + func forEachExpandsThroughBuilderArray() { + let stack = HStack(spacing: 1) { + for group in 1...2 { + ForEach(["A", "B"], id: \.self) { item in + Text("\(group)\(item)") + } + } + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["1A 1B 2A 2B"]) + } @Test("ForEach with empty array produces empty result") func forEachEmptyArray() { @@ -211,12 +236,8 @@ struct ForEachTests { Text(item.name) } - #expect(forEach.data.isEmpty) + let buffer = renderToBuffer(forEach, context: testContext()) - // Also test via ViewArray (which is what @ViewBuilder produces) - let viewArray = ViewArray([]) - let context = testContext() - let buffer = renderToBuffer(viewArray, context: context) #expect(buffer.isEmpty) } } diff --git a/Tests/TUIkitTests/ItemListHandlerTests.swift b/Tests/TUIkitTests/ItemListHandlerTests.swift index 444737ea3..55d423c31 100644 --- a/Tests/TUIkitTests/ItemListHandlerTests.swift +++ b/Tests/TUIkitTests/ItemListHandlerTests.swift @@ -350,6 +350,33 @@ struct ItemListHandlerSelectionTests { #expect(handler.isFocused(at: 1) == true) #expect(handler.isFocused(at: 2) == false) } + + @Test("Focus and selection follow item IDs across insertion and reorder") + func focusAndSelectionFollowReorder() { + var selectedID: String? = "b" + let handler = ItemListHandler( + focusID: "test", + itemCount: 3, + viewportHeight: 3, + selectionMode: .single + ) + handler.itemIDs = ["a", "b", "c"] + handler.singleSelection = Binding( + get: { selectedID }, + set: { selectedID = $0 } + ) + handler.focusedIndex = 1 + + handler.itemIDs = ["c", "d", "a", "b"] + + #expect(handler.focusedIndex == 3) + #expect(handler.isFocused(at: 3)) + #expect(handler.isSelected(at: 3)) + + _ = handler.handleKeyEvent(KeyEvent(key: .enter)) + + #expect(selectedID == nil) + } } // MARK: - Item List Handler Scroll Tests diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift new file mode 100644 index 000000000..d989f905f --- /dev/null +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -0,0 +1,535 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// KeyedCollectionRuntimeTests.swift +// +// License: MIT + +import Foundation +import Observation +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +@MainActor +@Suite("Keyed Collection Runtime", .serialized) +struct KeyedCollectionRuntimeTests { + @Test("ForEach renders each element inside a stack") + func forEachRendersEachElement() { + let harness = RuntimeCharacterizationHarness() + let actualLines = harness.render { + VStack { + ForEach(0..<2) { index in + Text("row:\(index)") + } + } + }.ansiStrippedLines + + #expect(actualLines == ["row:0", "row:1"]) + } + + @Test("ForEach nested inside ForEach flattens through stack builders") + func nestedForEachRendersAllElements() { + struct Group: Identifiable { + let id: String + let items: [String] + } + let groups = [ + Group(id: "g1", items: ["a", "b"]), + Group(id: "g2", items: ["c"]) + ] + let harness = RuntimeCharacterizationHarness() + let actualLines = harness.render { + VStack(spacing: 0) { + ForEach(groups) { group in + ForEach(group.items, id: \.self) { item in + Text("\(group.id):\(item)") + } + } + } + }.ansiStrippedLines + + #expect(actualLines == ["g1:a", "g1:b", "g2:c"]) + } + + @Test("ForEach State follows IDs across insertion, reorder, and removal") + func forEachStateFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + + let initial = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let inserted = harness.render { + VStack { + ForEach([3, 1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let reordered = harness.render { + VStack { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let removed = harness.render { + VStack { + ForEach([2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + + #expect(initial.ansiStrippedLines == ["1:1", "2:2"]) + #expect(inserted.ansiStrippedLines == ["3:3", "1:1", "2:2"]) + #expect(reordered.ansiStrippedLines == ["2:2", "1:1"]) + #expect(removed.ansiStrippedLines == ["2:2"]) + #expect(harness.storedStateCount == 1) + } + + @Test("ForEach lifecycle follows IDs across insertion, reorder, and removal") + func forEachLifecycleFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + _ = harness.render { + VStack { + ForEach([3, 2, 1], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:2") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:3") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:1")) == false) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + #expect(trace.snapshot().contains(.lifecycle("disappear:3")) == false) + + _ = harness.render { + VStack { + ForEach([2], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:3") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + #expect(harness.mountedLifecycleCallbackCount == 1) + } + + @Test("ForEach focus follows IDs across insertion, reorder, and removal") + func forEachFocusFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + HStack { + ForEach([1, 2], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + #expect(harness.dispatchFocusEvent(KeyEvent(key: .tab))) + let focusedItemTwo = harness.currentFocusedID + let initialStateIdentities = Set(harness.storedStateIdentityPaths) + + _ = harness.render { + HStack { + ForEach([3, 2, 1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(focusedItemTwo != nil) + #expect(harness.currentFocusedID == focusedItemTwo) + let insertedStateIdentities = Set(harness.storedStateIdentityPaths) + #expect(initialStateIdentities.isSubset(of: insertedStateIdentities)) + #expect(harness.dispatchFocusEvent(KeyEvent(key: .enter))) + #expect(trace.snapshot().contains(.effect("activate:2"))) + + _ = harness.render { + HStack { + ForEach([1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(harness.currentFocusedID != focusedItemTwo) + #expect(harness.storedStateCount < insertedStateIdentities.count) + #expect(Set(harness.storedStateIdentityPaths).isSubset(of: insertedStateIdentities)) + } + + @Test("ForEach tasks follow inserted IDs and cancel removed items", .timeLimit(.minutes(1))) + func forEachTasksFollowIDs() async { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + let first = KeyedTaskItem(id: 1) + let second = KeyedTaskItem(id: 2) + let third = KeyedTaskItem(id: 3) + + _ = harness.render { + VStack { + ForEach([first, second]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + await first.started.wait() + await second.started.wait() + + _ = harness.render { + VStack { + ForEach([third, second, first]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + await third.started.wait() + + #expect(trace.snapshot().filter { $0 == .task("start:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .task("start:2") }.count == 1) + #expect(trace.snapshot().filter { $0 == .task("start:3") }.count == 1) + #expect(harness.mountedTaskCount == 3) + + _ = harness.render { + VStack { + ForEach([second]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + await first.cancelled.wait() + await third.cancelled.wait() + + #expect(trace.snapshot().filter { $0 == .task("cancel:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .task("cancel:3") }.count == 1) + #expect(trace.snapshot().contains(.task("cancel:2")) == false) + #expect(harness.mountedTaskCount == 1) + + harness.unmount() + await second.cancelled.wait() + + #expect(harness.mountedTaskCount == 0) + } + + @Test("ForEach Observation follows inserted IDs and releases removed items") + func forEachObservationFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let first = ObservedForEachItem(id: 1) + let second = ObservedForEachItem(id: 2) + let third = ObservedForEachItem(id: 3) + + _ = harness.render { + VStack { + ForEach([first, second]) { item in + ObservedForEachRow(item: item) + } + } + } + let initialRegistrationCount = harness.observationRegistrationCount + first.model.value = 1 + let initialInvalidations = harness.consumePendingSubtreeInvalidations() + + #expect(initialInvalidations.count == 1) + + _ = harness.render { + VStack { + ForEach([third, second, first]) { item in + ObservedForEachRow(item: item) + } + } + } + first.model.value = 2 + let reorderedInvalidations = harness.consumePendingSubtreeInvalidations() + + #expect(harness.observationRegistrationCount == initialRegistrationCount + 1) + #expect(reorderedInvalidations == initialInvalidations) + + _ = harness.render { + VStack { + ForEach([second]) { item in + ObservedForEachRow(item: item) + } + } + } + let remainingRegistrationCount = harness.observationRegistrationCount + first.model.value = 3 + third.model.value = 1 + + #expect(remainingRegistrationCount < initialRegistrationCount) + #expect(harness.consumePendingSubtreeInvalidations().isEmpty) + + second.model.value = 1 + #expect(harness.consumePendingSubtreeInvalidations().count == 1) + } + + @Test("List row State follows ForEach IDs") + func listRowStateFollowsForEachIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: Int? + + let initial = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + let reordered = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + + #expect(initial.contains("1:1")) + #expect(initial.contains("2:2")) + #expect(reordered.contains("2:2")) + #expect(reordered.contains("1:1")) + } + + @Test("ForEach reports duplicate IDs deterministically") + func forEachReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + let items = duplicateItems + + let snapshot = harness.render { + VStack { + ForEach(items) { item in + Text(item.label) + } + } + } + + let renderedLines = snapshot.ansiStrippedLines.map { + $0.trimmingCharacters(in: .whitespaces) + } + #expect(renderedLines == ["First", "Second", "Third"]) + expectDuplicateDiagnostic(in: harness, container: "ForEach") + } + + @Test("Table reports duplicate IDs deterministically") + func tableReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: String? + + let snapshot = harness.render { + Table( + duplicateItems, + selection: Binding(get: { selection }, set: { selection = $0 }) + ) { + TableColumn("Label", value: \DuplicateForEachItem.label) + } + } + + let rendered = snapshot.ansiStrippedLines.joined(separator: "\n") + #expect(rendered.contains("First")) + #expect(rendered.contains("Second")) + #expect(rendered.contains("Third")) + expectDuplicateDiagnostic(in: harness, container: "Table") + } + + @Test("Table selection follows ForEach IDs across insert, delete, and reorder") + func tableSelectionFollowsForEachIDs() { + struct Row: Identifiable, Sendable { + let id: String + let label: String + } + + let harness = RuntimeCharacterizationHarness() + var selection: String? + + func renderTable(_ items: [Row]) -> [String] { + harness.render { + Table( + items, + selection: Binding(get: { selection }, set: { selection = $0 }) + ) { + TableColumn("Label", value: \Row.label) + } + }.ansiStrippedLines + } + + // Pre-seed selection on ID "b" and verify the marker sits on its row. + selection = "b" + let initial = renderTable([ + Row(id: "a", label: "Alpha"), + Row(id: "b", label: "Beta") + ]) + #expect(initial.contains { $0.contains("Alpha") }) + #expect(initial.contains { $0.contains("Beta") }) + #expect(initial.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(initial.first(where: { $0.contains("Alpha") })?.contains("●") == false) + + // Insert before, reorder existing: "b" must remain the marked row. + let inserted = renderTable([ + Row(id: "c", label: "Gamma"), + Row(id: "a", label: "Alpha"), + Row(id: "b", label: "Beta") + ]) + #expect(inserted.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(inserted.first(where: { $0.contains("Alpha") })?.contains("●") == false) + #expect(inserted.first(where: { $0.contains("Gamma") })?.contains("●") == false) + #expect(selection == "b") + + // Delete the selected ID: marker disappears, binding is not cleared by the renderer. + let deleted = renderTable([ + Row(id: "a", label: "Alpha") + ]) + #expect(deleted.first(where: { $0.contains("Alpha") })?.contains("●") == false) + #expect(selection == "b") + + // Re-add "b": marker returns on its row. + let restored = renderTable([ + Row(id: "b", label: "Beta"), + Row(id: "a", label: "Alpha") + ]) + #expect(restored.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(restored.first(where: { $0.contains("Alpha") })?.contains("●") == false) + } +} + +// MARK: - Helpers + +@MainActor +private func expectDuplicateDiagnostic( + in harness: RuntimeCharacterizationHarness, + container: String +) { + let message = harness.currentDiagnosticMessages.first ?? "" + #expect(harness.currentDiagnosticMessages.count == 1) + #expect(message.contains(container)) + #expect(message.contains("duplicate")) + #expect(message.contains("0, 2")) +} + +private let duplicateItems = [ + DuplicateForEachItem(id: "duplicate", label: "First"), + DuplicateForEachItem(id: "unique", label: "Second"), + DuplicateForEachItem(id: "duplicate", label: "Third"), +] + +// MARK: - Fixtures + +private struct StatefulForEachRow: View { + @State private var storedID = 0 + + let id: Int + + var body: some View { + if storedID == 0 { + storedID = id + } + return Text("\(id):\(storedID)") + } +} + +private struct DuplicateForEachItem: Identifiable, Sendable { + let id: String + let label: String +} + +private struct KeyedTaskItem: Identifiable, Sendable { + let id: Int + let started = AsyncSignal() + let cancelled = AsyncSignal() +} + +private struct KeyedLifecycleRow: View { + let id: Int + let trace: TraceRecorder + + var body: some View { + Text("\(id)") + .onAppear { + trace.record(.lifecycle("appear:\(id)")) + } + .onDisappear { + trace.record(.lifecycle("disappear:\(id)")) + } + } +} + +private struct KeyedTaskRow: View { + let item: KeyedTaskItem + let trace: TraceRecorder + + var body: some View { + Text("task:\(item.id)") + .task { + trace.record(.task("start:\(item.id)")) + item.started.signal() + + // The .task closure inherits @MainActor from the render path. + // Task.sleep would suspend on the main actor, and under heavy + // main-actor load from parallel tests in CI the cancellation + // notification can be starved, causing the test to time out. + // Hopping to a nonisolated function moves the sleep to the + // global executor so cancellation is processed independently + // of main-actor scheduling. + await Self.awaitCancellation( + id: item.id, + trace: trace, + signal: item.cancelled + ) + } + } + + nonisolated private static func awaitCancellation( + id: Int, + trace: TraceRecorder, + signal: AsyncSignal + ) async { + // 1e12 ns (1000 s) is well within Int64 and far exceeds the 60 s + // test time limit, so the sleep only returns via cancellation. + // Task.isCancelled checks the current task's flag, which is the + // same task even when executing a nonisolated function. + try? await Task.sleep(nanoseconds: 1_000_000_000_000) + if Task.isCancelled { + trace.record(.task("cancel:\(id)")) + signal.signal() + } + } +} + +private struct ObservedForEachItem: Identifiable { + let id: Int + let model = KeyedObservationModel() +} + +private struct ObservedForEachRow: View { + let item: ObservedForEachItem + + var body: some View { + Text("\(item.id):\(item.model.value)") + } +} + +@Observable +private final class KeyedObservationModel { + var value = 0 +} diff --git a/Tests/TUIkitTests/LazyStacksTests.swift b/Tests/TUIkitTests/LazyStacksTests.swift index 3d70d66a4..ffadd0e7e 100644 --- a/Tests/TUIkitTests/LazyStacksTests.swift +++ b/Tests/TUIkitTests/LazyStacksTests.swift @@ -110,6 +110,30 @@ struct LazyVStackTests { #expect(buffer.isEmpty) } + + @Test("LazyVStack expands conditional ForEach children with stack spacing") + func expandsConditionalForEach() { + let includeItems = true + let stack = LazyVStack(spacing: 1) { + Text("Before") + if includeItems { + ForEach(["Alpha", "Beta"], id: \.self) { item in + Text(item) + } + } else { + EmptyView() + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.height == 7) + #expect(buffer.lines.contains { $0.contains("Before") }) + #expect(buffer.lines.contains { $0.contains("Alpha") }) + #expect(buffer.lines.contains { $0.contains("Beta") }) + #expect(buffer.lines.contains { $0.contains("After") }) + } } // MARK: - LazyHStack Tests @@ -187,6 +211,26 @@ struct LazyHStackTests { #expect(buffer.isEmpty) } + + @Test("LazyHStack expands conditional ForEach children horizontally") + func expandsConditionalForEach() { + let includeItems = true + let stack = LazyHStack(spacing: 1) { + Text("Before") + if includeItems { + ForEach(["Alpha", "Beta"], id: \.self) { item in + Text(item) + } + } else { + EmptyView() + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["Before Alpha Beta After"]) + } } // MARK: - Equatable Tests diff --git a/Tests/TUIkitTests/RenderBottleneckTests.swift b/Tests/TUIkitTests/RenderBottleneckTests.swift index df5a39418..fbc1e1762 100644 --- a/Tests/TUIkitTests/RenderBottleneckTests.swift +++ b/Tests/TUIkitTests/RenderBottleneckTests.swift @@ -163,10 +163,7 @@ struct RenderBottleneckTests { // MARK: - ForEach Analysis - @Test( - "Analyze ForEach iteration count impact", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("Analyze ForEach iteration count impact") func analyzeForEachIterations() throws { let context = testContext() let iterations = 200 @@ -202,16 +199,16 @@ struct RenderBottleneckTests { } try requireRenderedOutput(forEach5, context: context) { - $0.strippedLines == items5.map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items5.map { "Row \($0)" }) } try requireRenderedOutput(forEach20, context: context) { - $0.strippedLines == items20.map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items20.map { "Row \($0)" }) } try requireRenderedOutput(forEach50, context: context) { - $0.strippedLines == items50.prefix(context.availableHeight).map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items50.map { "Row \($0)" }) } try requireRenderedOutput(forEach100, context: context) { - $0.strippedLines == items100.prefix(context.availableHeight).map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items100.map { "Row \($0)" }) } _ = measure("5 items", iterations: iterations) { @@ -437,10 +434,7 @@ struct RenderBottleneckTests { // MARK: - LazyStack vs Regular Stack - @Test( - "Compare LazyStack vs regular Stack performance", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("Compare LazyStack vs regular Stack performance") func compareLazyVsRegular() throws { let iterations = 300 @@ -461,12 +455,15 @@ struct RenderBottleneckTests { } } - let expectedOutput = items.prefix(smallContext.availableHeight).map { "Row \($0)" } + let lines = items.map { "Row \($0)" } try requireRenderedOutput(regularStack, context: smallContext) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines(lines) } try requireRenderedOutput(lazyStack, context: smallContext) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines( + lines, + visibleCount: smallContext.availableHeight + ) } let regularTime = measure("VStack (100 items, 10 visible)", iterations: iterations) { diff --git a/Tests/TUIkitTests/RenderPerformanceTests.swift b/Tests/TUIkitTests/RenderPerformanceTests.swift index 817e88775..39147f095 100644 --- a/Tests/TUIkitTests/RenderPerformanceTests.swift +++ b/Tests/TUIkitTests/RenderPerformanceTests.swift @@ -310,10 +310,7 @@ struct RenderPerformanceTests { // MARK: - Comparative Tests - @Test( - "VStack vs LazyVStack performance comparison", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("VStack vs LazyVStack performance comparison") func vstackVsLazyVstackComparison() throws { let items = Array(0..<10) let regularStack = VStack { @@ -329,12 +326,15 @@ struct RenderPerformanceTests { } let context = testContext(height: 5) // Only 5 lines visible - let expectedOutput = items.prefix(5).map { "Row \($0)" } + let lines = items.map { "Row \($0)" } try requireRenderedOutput(regularStack, context: context) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines(lines) } try requireRenderedOutput(lazyStack, context: context) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines( + lines, + visibleCount: context.availableHeight + ) } let regularTime = measureRenderTime(regularStack, iterations: 500, context: context) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index dc160283c..70aec56f5 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -3,6 +3,7 @@ // // License: MIT +import Foundation import Observation import Testing import TUIkitTestSupport @@ -176,25 +177,6 @@ struct RuntimeCharacterizationTests { #expect(harness.trace.snapshot() == [.effect("preference committed")]) } - @Test("ForEach output remains characterized for issue #12") - func knownForEachOutputDefect() { - let harness = RuntimeCharacterizationHarness() - let actualLines = harness.render { - VStack { - ForEach(0..<2) { index in - Text("row:\(index)") - } - } - }.ansiStrippedLines - - withKnownIssue("Issue #12: ForEach is not rendered outside the List-specific path") { - #expect(actualLines == ["row:0", "row:1"]) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } - } - @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() diff --git a/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift b/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift index 100ed25bc..0285aa527 100644 --- a/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift +++ b/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift @@ -29,3 +29,21 @@ func requireRenderedOutput( ) try #require(predicate(output)) } + +/// Produces the exact centered line layout used by vertical stacks. +func expectedCenteredLines( + _ lines: [String], + visibleCount: Int? = nil +) -> [String] { + let width = lines.map(\.count).max() ?? 0 + let visibleLines = visibleCount.map { Array(lines.prefix($0)) } ?? lines + + return visibleLines.map { line in + let padding = width - line.count + let leftPadding = padding / 2 + let rightPadding = padding - leftPadding + return String(repeating: " ", count: leftPadding) + + line + + String(repeating: " ", count: rightPadding) + } +} diff --git a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift index 037c02976..8a100c0af 100644 --- a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift +++ b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift @@ -22,15 +22,40 @@ enum RuntimeTraceEvent: Sendable, Equatable { final class RuntimeCharacterizationHarness { let trace = TraceRecorder() + var storedStateCount: Int { + stateStorage.count + } + + var storedStateIdentityPaths: [String] { + stateStorage.storedIdentities.map(\.path).sorted() + } + + var currentDiagnosticMessages: [String] { + tuiContext.runtimeDiagnostics.messages + } + + var mountedLifecycleCallbackCount: Int { + lifecycle.disappearCallbackCount + } + + var mountedTaskCount: Int { + lifecycle.taskCount + } + + var observationRegistrationCount: Int { + tuiContext.observationRegistry.count + } + + var currentFocusedID: String? { + tuiContext.focusManager.currentFocusedID + } + private let availableWidth: Int private let availableHeight: Int private let rootIdentity = ViewIdentity(path: "RuntimeCharacterizationRoot") private let lifecycle: LifecycleManager - private let keyEventDispatcher: KeyEventDispatcher - private let preferences: PreferenceStorage private let stateStorage: StateStorage - private let renderCache: RenderCache private let tuiContext: TUIContext init(availableWidth: Int = 80, availableHeight: Int = 24) { @@ -38,22 +63,13 @@ final class RuntimeCharacterizationHarness { self.availableHeight = availableHeight let lifecycle = LifecycleManager() - let keyEventDispatcher = KeyEventDispatcher() - let preferences = PreferenceStorage() let stateStorage = StateStorage() - let renderCache = RenderCache() self.lifecycle = lifecycle - self.keyEventDispatcher = keyEventDispatcher - self.preferences = preferences self.stateStorage = stateStorage - self.renderCache = renderCache self.tuiContext = TUIContext( lifecycle: lifecycle, - keyEventDispatcher: keyEventDispatcher, - preferences: preferences, - stateStorage: stateStorage, - renderCache: renderCache + stateStorage: stateStorage ) } @@ -61,16 +77,11 @@ final class RuntimeCharacterizationHarness { func withRenderPass( _ operation: (RenderContext) throws -> Result ) rethrows -> Result { - keyEventDispatcher.clearHandlers() - preferences.beginRenderPass() - lifecycle.beginRenderPass() - stateStorage.beginRenderPass() - renderCache.beginRenderPass() + tuiContext.beginRenderPass() defer { - lifecycle.endRenderPass() - stateStorage.endRenderPass() - renderCache.removeInactive() + tuiContext.focusManager.endRenderPass() + tuiContext.endRenderPass() } let context = RenderContext( @@ -119,6 +130,18 @@ final class RuntimeCharacterizationHarness { pass() } + @discardableResult + func dispatchFocusEvent(_ event: KeyEvent) -> Bool { + tuiContext.focusManager.dispatchKeyEvent(event) + } + + func consumePendingSubtreeInvalidations() -> [ViewIdentity] { + tuiContext.appState.consumePendingCacheInvalidations().compactMap { invalidation in + guard case .subtree(let identity) = invalidation else { return nil } + return identity + } + } + func recordEffect(_ description: String) { trace.record(.effect(description)) } diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index c2a5eee8a..c75e5b39d 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -337891,6 +337891,16 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView011ConditionalB0O" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -338421,6 +338431,11 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView0B5ArrayV10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -344519,7 +344534,17 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV4bodys5NeverOvp" + "symbolID" : "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit7ForEachV10childViews7contextSay0A4View05ChildG0VGAF13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit7ForEachV4bodyQrvp" }, { "classification" : "implementationLeak", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index 89ce9417e..d63a1e38c 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -25041,7 +25041,7 @@ { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachV4bodys5NeverOvp" + "symbolID": "s:6TUIkit7ForEachV4bodyQrvp" }, { "action": "implementationLeak", @@ -25058,6 +25058,32 @@ "ownerIssue": "#35", "symbolID": "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit7ForEachV10childViews7contextSay0A4View05ChildG0VGAF13RenderContextV_tF" + }, + { "action": "implementationLeak", "ownerIssue": "#35",